feat: add approvable interactive UML stages
This commit is contained in:
+266
-16
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+118
-11
@@ -167,8 +167,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #bbb;
|
||||
background: #fafafa;
|
||||
}
|
||||
@@ -186,31 +186,93 @@
|
||||
}
|
||||
.uml-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.uml-actions button {
|
||||
border: 1px solid #777;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 2px;
|
||||
background: #fcfcfc;
|
||||
color: #444;
|
||||
padding: 2px 6px;
|
||||
font:
|
||||
600 10px/1.25 ui-monospace,
|
||||
monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
.uml-actions button:hover {
|
||||
border-color: #64879a;
|
||||
background: #f5fafc;
|
||||
}
|
||||
.uml-actions button[data-completed="true"] {
|
||||
border-color: #62a47f;
|
||||
background: #edf8f1;
|
||||
color: #14613a;
|
||||
}
|
||||
.uml-actions button[aria-pressed="true"] {
|
||||
border-color: #315f78;
|
||||
background: #eaf4fa;
|
||||
font-weight: 700;
|
||||
border-color: #287495;
|
||||
background: #edf7fb;
|
||||
color: #174d64;
|
||||
box-shadow: inset 0 0 0 1px #b9dce9;
|
||||
}
|
||||
.uml-canvas {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.uml-canvas img {
|
||||
.uml-canvas img,
|
||||
.uml-svg-host svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-height: 760px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.uml-svg-host {
|
||||
cursor: pointer;
|
||||
}
|
||||
.uml-stage-region {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
fill 140ms ease,
|
||||
stroke 140ms ease,
|
||||
stroke-width 140ms ease;
|
||||
}
|
||||
.uml-stage-region-completed.uml-stage-region-bg {
|
||||
fill: #e7f6ed !important;
|
||||
fill-opacity: 1 !important;
|
||||
stroke: #258154 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
.uml-stage-region-completed.uml-stage-region-outline {
|
||||
stroke: #258154 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
.uml-stage-region-active.uml-stage-region-bg {
|
||||
fill: #e7f6fc !important;
|
||||
fill-opacity: 1 !important;
|
||||
stroke: #187ea7 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
.uml-stage-region-active.uml-stage-region-outline {
|
||||
stroke: #187ea7 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
.uml-stage-region-active.uml-stage-region-completed.uml-stage-region-bg {
|
||||
fill: #e7f6ed !important;
|
||||
}
|
||||
.uml-stage-region:focus {
|
||||
outline: none;
|
||||
stroke: #005f87 !important;
|
||||
stroke-width: 4px !important;
|
||||
}
|
||||
.uml-inline-warning {
|
||||
display: block;
|
||||
padding-top: 5px;
|
||||
color: #8b2d20;
|
||||
font-size: 11px;
|
||||
}
|
||||
.uml-stage {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
@@ -227,6 +289,50 @@
|
||||
grid-column: 2;
|
||||
font-size: 11px;
|
||||
}
|
||||
.uml-approval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 8px 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #d2d2d2;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
}
|
||||
.uml-approval button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #477a5d;
|
||||
border-radius: 2px;
|
||||
background: #edf8f1;
|
||||
color: #174e30;
|
||||
padding: 3px 7px;
|
||||
font: 600 10px/1.25 system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
.uml-approval button:disabled {
|
||||
border-color: #bbb;
|
||||
background: #f3f3f3;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.task-conclusion {
|
||||
margin: 8px 0 0;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #315f78;
|
||||
background: #f3f9fc;
|
||||
}
|
||||
.task-conclusion > strong {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: #315f78;
|
||||
font:
|
||||
700 10px/1.2 ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
.task-conclusion p {
|
||||
margin: 0;
|
||||
}
|
||||
.asset-source-link {
|
||||
display: inline-block;
|
||||
margin: 5px 0;
|
||||
@@ -320,6 +426,7 @@
|
||||
}
|
||||
.uml-actions,
|
||||
.uml-stage,
|
||||
.uml-approval,
|
||||
.asset-source-link,
|
||||
.lesson-progress-dock,
|
||||
.lesson-progress-control {
|
||||
|
||||
Reference in New Issue
Block a user