feat: add approvable interactive UML stages

This commit is contained in:
user
2026-07-16 14:10:16 +02:00
parent 41804a8ad1
commit 1c06ca35d6
4 changed files with 440 additions and 36 deletions
+266 -16
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./react.css";
@@ -15,6 +15,8 @@ type UmlStage = {
label: string;
description: string;
code_ref?: string;
progress_id?: string;
svg_label?: string;
};
type InteractiveAsset = {
kind: "allocator-memory-flow" | "uml-sequence";
@@ -47,6 +49,7 @@ type Task = {
title: string;
uuid?: string;
criterion: string;
conclusion_html: string;
prompt_html?: string;
flow: FlowBlock[];
};
@@ -66,6 +69,7 @@ type Progress = {
items: Record<string, { status: Status }>;
summary: { total: number; counts: Record<Status, number> };
};
type SetProgressStatus = (id: string, status: Status) => Promise<void>;
type CardModel = {
schema: string;
card: Record<string, string>;
@@ -396,12 +400,20 @@ function InteractiveMemoryFlow({
function InteractiveUmlSequence({
config,
asset,
progress,
onSetStatus,
}: {
config: InteractiveAsset;
asset: Asset;
progress: Progress | null;
onSetStatus: SetProgressStatus;
}) {
const stages = config.stages ?? [];
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
const canvasRef = useRef<HTMLDivElement>(null);
const [svgMarkup, setSvgMarkup] = useState("");
const [svgFailed, setSvgFailed] = useState(false);
const [approvalBusy, setApprovalBusy] = useState(false);
const [stageIndex, setStageIndex] = useState(() =>
Math.max(
0,
@@ -415,6 +427,156 @@ function InteractiveUmlSequence({
() => localStorage.setItem(storageKey, String(stageIndex)),
[stageIndex, storageKey],
);
useEffect(() => {
let current = true;
setSvgFailed(false);
fetch(asset.href, { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then((markup) => {
const document = new DOMParser().parseFromString(
markup,
"image/svg+xml",
);
const svg = document.documentElement;
if (svg.nodeName.toLowerCase() !== "svg" || document.querySelector("parsererror")) {
throw new Error("Niepoprawny SVG");
}
document.querySelectorAll("script, foreignObject").forEach((node) => node.remove());
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
if (current) setSvgMarkup(svg.outerHTML);
})
.catch(() => {
if (current) setSvgFailed(true);
});
return () => {
current = false;
};
}, [asset.href]);
useEffect(() => {
const svg = canvasRef.current?.querySelector("svg");
if (!svg) return;
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", asset.alt);
svg
.querySelectorAll<SVGRectElement>("[data-uml-stage]")
.forEach((rect) => {
rect.classList.remove(
"uml-stage-region",
"uml-stage-region-bg",
"uml-stage-region-outline",
"uml-stage-region-active",
"uml-stage-region-completed",
);
rect.removeAttribute("data-uml-stage");
rect.removeAttribute("data-uml-stage-label");
rect.removeAttribute("role");
rect.removeAttribute("tabindex");
rect.removeAttribute("aria-label");
});
const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
const rects = Array.from(svg.querySelectorAll<SVGRectElement>("rect"));
stages.forEach((item, index) => {
if (!item.svg_label) return;
const label = texts.find((text) =>
(text.textContent ?? "").trim().startsWith(item.svg_label!),
);
const labelY = Number(label?.getAttribute("y"));
if (!label || !Number.isFinite(labelY)) return;
const candidates = rects
.map((rect) => {
const y = Number(rect.getAttribute("y"));
const height = Number(rect.getAttribute("height"));
const width = Number(rect.getAttribute("width"));
const style = rect.getAttribute("style") ?? "";
const strokeWidth = Number(
style.match(/stroke-width:\s*([\d.]+)/)?.[1] ??
rect.getAttribute("stroke-width") ??
0,
);
return { rect, y, height, width, strokeWidth, area: width * height };
})
.filter(
({ y, height, width, strokeWidth }) =>
Number.isFinite(y) &&
Number.isFinite(height) &&
Number.isFinite(width) &&
strokeWidth >= 2 &&
y <= labelY &&
y + height >= labelY,
)
.sort((left, right) => left.area - right.area);
const region = candidates[0];
if (!region) return;
const sameRegion = candidates.filter(
({ y, height, width }) =>
Math.abs(y - region.y) < 0.1 &&
Math.abs(height - region.height) < 0.1 &&
Math.abs(width - region.width) < 0.1,
);
const isCompleted = Boolean(
item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed",
);
sameRegion.forEach(({ rect }, duplicateIndex) => {
const isBackground = rect.getAttribute("fill") !== "none";
rect.dataset.umlStage = String(index);
rect.dataset.umlStageLabel = item.label;
rect.classList.add(
"uml-stage-region",
isBackground ? "uml-stage-region-bg" : "uml-stage-region-outline",
);
rect.classList.toggle("uml-stage-region-active", index === stageIndex);
rect.classList.toggle("uml-stage-region-completed", isCompleted);
if (duplicateIndex === 0) {
rect.setAttribute("role", "button");
rect.setAttribute("tabindex", "0");
rect.setAttribute("aria-label", `Pokaż etap ${item.label}`);
}
});
});
}, [asset.alt, progress, stageIndex, stages, svgMarkup]);
const selectStageAtPoint = (clientX: number, clientY: number) => {
const svg = canvasRef.current?.querySelector("svg");
if (!svg) return;
const hit = Array.from(
svg.querySelectorAll<SVGRectElement>(".uml-stage-region-bg[data-uml-stage]"),
)
.filter((rect) => {
const bounds = rect.getBoundingClientRect();
return (
clientX >= bounds.left &&
clientX <= bounds.right &&
clientY >= bounds.top &&
clientY <= bounds.bottom
);
})
.sort((left, right) => {
const a = left.getBoundingClientRect();
const b = right.getBoundingClientRect();
return a.width * a.height - b.width * b.height;
})[0];
const next = Number(hit?.dataset.umlStage);
if (Number.isInteger(next)) setStageIndex(next);
};
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
const target = event.target as SVGRectElement;
const next = Number(target.dataset?.umlStage);
if (!Number.isInteger(next)) return;
event.preventDefault();
setStageIndex(next);
};
const stage = stages[stageIndex];
return (
<div className="uml-react">
@@ -428,21 +590,45 @@ function InteractiveUmlSequence({
<button
key={item.id}
aria-pressed={stageIndex === index}
data-completed={
item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed"
? "true"
: "false"
}
onClick={() => setStageIndex(index)}
>
{item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed" && (
<span aria-hidden="true"> </span>
)}
{item.label}
</button>
))}
</div>
</header>
<a
<div
ref={canvasRef}
className="uml-canvas"
href={asset.href}
target="_blank"
rel="noopener"
onClick={(event) => selectStageAtPoint(event.clientX, event.clientY)}
onKeyDown={handleSvgKeyDown}
>
<img src={asset.href} alt={asset.alt} />
</a>
{svgMarkup ? (
<div
className="uml-svg-host"
// The SVG is a generated, same-origin PlantUML asset; scripts and
// foreignObject nodes are removed before it reaches the DOM.
dangerouslySetInnerHTML={{ __html: svgMarkup }}
/>
) : (
<img src={asset.href} alt={asset.alt} />
)}
{svgFailed && (
<span className="uml-inline-warning">
Widok statyczny nie udało się wczytać interaktywnego SVG.
</span>
)}
</div>
{stage && (
<aside className="uml-stage">
<strong>{stage.label}</strong>
@@ -450,11 +636,53 @@ function InteractiveUmlSequence({
{stage.code_ref && <code>{stage.code_ref}</code>}
</aside>
)}
{stage?.progress_id && (
<footer className="uml-approval">
<span>
{progress?.items?.[stage.progress_id]?.status === "completed"
? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć."
: "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
</span>
<button
disabled={!progress || approvalBusy}
onClick={async () => {
if (!stage.progress_id || !progress) return;
const completed =
progress.items?.[stage.progress_id]?.status === "completed";
setApprovalBusy(true);
try {
await onSetStatus(
stage.progress_id,
completed ? "not_started" : "completed",
);
} finally {
setApprovalBusy(false);
}
}}
>
{approvalBusy
? "Zapisywanie…"
: progress?.items?.[stage.progress_id]?.status === "completed"
? "Cofnij zatwierdzenie"
: "Zatwierdź stan"}
</button>
</footer>
)}
</div>
);
}
function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
function AssetFigure({
asset,
number,
progress,
onSetStatus,
}: {
asset: Asset;
number: number;
progress: Progress | null;
onSetStatus: SetProgressStatus;
}) {
const interactiveKind = asset.interactive?.kind;
const isInteractive = Boolean(interactiveKind);
return (
@@ -463,7 +691,12 @@ function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
id={asset.label.replace(":", "-")}
>
{interactiveKind === "uml-sequence" ? (
<InteractiveUmlSequence config={asset.interactive!} asset={asset} />
<InteractiveUmlSequence
config={asset.interactive!}
asset={asset}
progress={progress}
onSetStatus={onSetStatus}
/>
) : interactiveKind === "allocator-memory-flow" ? (
<>
<InteractiveMemoryFlow
@@ -548,11 +781,13 @@ function SectionPage({
section,
progress,
onCycle,
onSetStatus,
figureOffset,
}: {
section: Section;
progress: Progress | null;
onCycle: (id: string, status: Status) => void;
onSetStatus: SetProgressStatus;
figureOffset: number;
}) {
return (
@@ -579,8 +814,19 @@ function SectionPage({
key={asset.label}
asset={asset}
number={figureOffset + index + 1}
progress={progress}
onSetStatus={onSetStatus}
/>
))}
{section.tasks.map(
(task) =>
task.conclusion_html && (
<aside className="task-conclusion" key={`${task.ref}-conclusion`}>
<strong>WNIOSEK</strong>
<Html html={task.conclusion_html} />
</aside>
),
)}
</Sheet>
);
}
@@ -655,17 +901,12 @@ function App({ model }: { model: CardModel }) {
window.addEventListener("wheel", wheel, { passive: false });
return () => window.removeEventListener("wheel", wheel);
}, []);
const cycle = async (id: string, current: Status) => {
const next: Record<Status, Status> = {
not_started: "discussed",
discussed: "completed",
completed: "not_started",
};
const setStatus: SetProgressStatus = async (id, status) => {
try {
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: next[current] }),
body: JSON.stringify({ status }),
});
if (!response.ok) throw new Error(await response.text());
setProgress((await response.json()).progress);
@@ -676,6 +917,14 @@ function App({ model }: { model: CardModel }) {
);
}
};
const cycle = (id: string, current: Status) => {
const next: Record<Status, Status> = {
not_started: "discussed",
discussed: "completed",
completed: "not_started",
};
void setStatus(id, next[current]);
};
let figureCount = 0;
return (
<>
@@ -691,6 +940,7 @@ function App({ model }: { model: CardModel }) {
section={section}
progress={progress}
onCycle={cycle}
onSetStatus={setStatus}
figureOffset={offset}
/>
);