feat: add React card renderer and interactive UML
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const outputDirectory = path.resolve(process.argv[2] ?? "web");
|
||||
const reactDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
await build({
|
||||
entryPoints: [path.join(reactDirectory, "src/main.tsx")],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
outfile: path.join(outputDirectory, "app.js"),
|
||||
loader: { ".tsx": "tsx" },
|
||||
});
|
||||
@@ -0,0 +1,729 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./react.css";
|
||||
|
||||
type Status = "not_started" | "discussed" | "completed";
|
||||
type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text";
|
||||
type InteractiveSymbol = {
|
||||
id: string;
|
||||
area: InteractiveArea;
|
||||
declaration: string;
|
||||
description: string;
|
||||
};
|
||||
type UmlStage = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
code_ref?: string;
|
||||
};
|
||||
type InteractiveAsset = {
|
||||
kind: "allocator-memory-flow" | "uml-sequence";
|
||||
storage_key?: string;
|
||||
arena_size?: number;
|
||||
allocations?: number[];
|
||||
symbols?: InteractiveSymbol[];
|
||||
stages?: UmlStage[];
|
||||
};
|
||||
type Asset = {
|
||||
label: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
href: string;
|
||||
width: number;
|
||||
kind: string;
|
||||
source_href?: string;
|
||||
interactive?: InteractiveAsset | null;
|
||||
};
|
||||
type FlowStep = { id: string; title: string; content_html: string };
|
||||
type FlowBlock = {
|
||||
id: string;
|
||||
kind: "block" | "exercise";
|
||||
title: string;
|
||||
content_html: string;
|
||||
steps: FlowStep[];
|
||||
};
|
||||
type Task = {
|
||||
ref: string;
|
||||
title: string;
|
||||
uuid?: string;
|
||||
criterion: string;
|
||||
prompt_html?: string;
|
||||
flow: FlowBlock[];
|
||||
};
|
||||
type Section = {
|
||||
id: string;
|
||||
index: number;
|
||||
title: string;
|
||||
header_html: string;
|
||||
left_margin_html: string;
|
||||
right_margin_html: string;
|
||||
content_html: string;
|
||||
tasks: Task[];
|
||||
assets: Asset[];
|
||||
steps: { id: string; title: string }[];
|
||||
};
|
||||
type Progress = {
|
||||
items: Record<string, { status: Status }>;
|
||||
summary: { total: number; counts: Record<Status, number> };
|
||||
};
|
||||
type CardModel = {
|
||||
schema: string;
|
||||
card: Record<string, string>;
|
||||
total_pages: number;
|
||||
front: any;
|
||||
sections: Section[];
|
||||
dictionary: { header_html: string; content_html: string };
|
||||
toc: { id: string; label: string }[];
|
||||
};
|
||||
|
||||
const Html = ({ html, className }: { html: string; className?: string }) => (
|
||||
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
);
|
||||
|
||||
function Header({ html }: { html: string }) {
|
||||
return <Html html={html} />;
|
||||
}
|
||||
|
||||
function Margin({ side, html }: { side: "left" | "right"; html: string }) {
|
||||
return (
|
||||
<aside
|
||||
className={`pdf-margin ${side}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Sheet({
|
||||
header,
|
||||
left = "",
|
||||
right = "",
|
||||
children,
|
||||
className = "",
|
||||
}: React.PropsWithChildren<any>) {
|
||||
return (
|
||||
<article className={`sheet ${className}`}>
|
||||
<div className="sheet-content">
|
||||
<Header html={header} />
|
||||
<Margin side="left" html={left} />
|
||||
<section className="pdf-main card-section">{children}</section>
|
||||
<Margin side="right" html={right} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Topbar({
|
||||
toc,
|
||||
scale,
|
||||
setScale,
|
||||
}: {
|
||||
toc: CardModel["toc"];
|
||||
scale: number;
|
||||
setScale: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="topbar">
|
||||
<div className="topbar-inner">
|
||||
<details>
|
||||
<summary>Spis treści</summary>
|
||||
<div className="toc-links">
|
||||
{toc.map((item) => (
|
||||
<a key={item.id} href={`#${item.id}`}>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<div className="viewer-zoom-controls">
|
||||
<output className="viewer-zoom-status">
|
||||
Płótno {Math.round(scale * 100)}% · Treść 100%
|
||||
</output>
|
||||
<button className="viewer-zoom-reset" onClick={() => setScale(2.18)}>
|
||||
Reset zoom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function FrontPage({ front }: { front: any }) {
|
||||
return (
|
||||
<Sheet
|
||||
header={front.header_html}
|
||||
left={front.left_margin_html}
|
||||
right={front.right_margin_html}
|
||||
className="front-sheet"
|
||||
>
|
||||
<section className="front-scope front-goal">
|
||||
<h2>{front.goal_title}</h2>
|
||||
<Html html={front.goal_html} />
|
||||
</section>
|
||||
<section className="front-scope front-card-scope">
|
||||
<h2>{front.scope_title}</h2>
|
||||
{front.scope_html && <Html html={front.scope_html} />}
|
||||
<div className="scope-table-wrap">
|
||||
<table className="scope-table scope-table--task">
|
||||
<thead>
|
||||
<tr>
|
||||
{front.scope_headers.map((header: string) => (
|
||||
<th key={header}>{header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{front.scope_rows.map((row: any) => (
|
||||
<tr
|
||||
key={`${row.chapter}-${row.task}`}
|
||||
className={row.key ? "scope-row--key" : ""}
|
||||
>
|
||||
<td>
|
||||
<code>{row.chapter}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{row.task}</code>
|
||||
</td>
|
||||
<td dangerouslySetInnerHTML={{ __html: row.idea_html }} />
|
||||
<td>{row.priority}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskStep({ taskRef, step }: { taskRef: string; step: FlowStep }) {
|
||||
return (
|
||||
<li id={`${taskRef}-${step.id}`}>
|
||||
<strong>{step.title}</strong>
|
||||
<Html html={step.content_html} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskBlock({ taskRef, block }: { taskRef: string; block: FlowBlock }) {
|
||||
const label = block.kind === "exercise" ? "Ćwiczenie" : "Block";
|
||||
return (
|
||||
<section
|
||||
className={`task-block task-block--${block.kind}`}
|
||||
id={`${taskRef}-${block.id}`}
|
||||
>
|
||||
<header>
|
||||
<span>{label}</span>
|
||||
<h4>{block.title}</h4>
|
||||
</header>
|
||||
<Html html={block.content_html} />
|
||||
{block.steps.length > 0 && (
|
||||
<ol className="block-steps">
|
||||
{block.steps.map((step) => (
|
||||
<TaskStep key={step.id} taskRef={taskRef} step={step} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFlow({ task }: { task: Task }) {
|
||||
if (!task.flow.length)
|
||||
return (
|
||||
<article className="task-legacy" id={task.ref}>
|
||||
<strong>{task.ref.toUpperCase()}</strong>
|
||||
<Html html={task.prompt_html ?? ""} />
|
||||
<footer>
|
||||
<strong>Task acceptance:</strong> {task.criterion}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<article className="task-flow" id={task.ref}>
|
||||
<header className="task-flow-header">
|
||||
<span>{task.ref.toUpperCase()}</span>
|
||||
<h3>{task.title}</h3>
|
||||
{task.uuid && <code>{task.uuid}</code>}
|
||||
</header>
|
||||
{task.flow.map((block) => (
|
||||
<TaskBlock key={block.id} taskRef={task.ref} block={block} />
|
||||
))}
|
||||
<footer>
|
||||
<strong>Task acceptance:</strong> {task.criterion}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const areaLabels: Record<InteractiveArea, string> = {
|
||||
compile: "KOMPILACJA",
|
||||
bss: ".BSS",
|
||||
stack: "STOS",
|
||||
registers: "REJESTRY",
|
||||
text: ".TEXT",
|
||||
};
|
||||
const allocationColors = ["#9bcfe2", "#c4dfc0", "#f4c889", "#d2b6dc"];
|
||||
|
||||
function InteractiveMemoryFlow({
|
||||
config,
|
||||
title,
|
||||
}: {
|
||||
config: InteractiveAsset;
|
||||
title: string;
|
||||
}) {
|
||||
const arenaSize = config.arena_size ?? 64;
|
||||
const allocations = config.allocations ?? [5, 7];
|
||||
const symbols = config.symbols ?? [];
|
||||
const storageKey = `${config.storage_key ?? "allocator-memory-flow"}-stage`;
|
||||
const [stage, setStage] = useState(() =>
|
||||
Math.min(allocations.length, Number(localStorage.getItem(storageKey) ?? 0)),
|
||||
);
|
||||
const [focus, setFocus] = useState(symbols[0]?.id ?? "");
|
||||
useEffect(
|
||||
() => localStorage.setItem(storageKey, String(stage)),
|
||||
[stage, storageKey],
|
||||
);
|
||||
const used = allocations.slice(0, stage).reduce((sum, size) => sum + size, 0);
|
||||
const focused = symbols.find((symbol) => symbol.id === focus) ?? symbols[0];
|
||||
const cells = Array.from({ length: arenaSize }, (_, index) => index);
|
||||
const allocationIndex = (cell: number) => {
|
||||
let end = 0;
|
||||
for (let index = 0; index < stage; index += 1) {
|
||||
end += allocations[index];
|
||||
if (cell < end) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const stageText =
|
||||
stage === 0
|
||||
? "allocator_reset(): allocp = allocbuf"
|
||||
: `alloc_local(${allocations[stage - 1]}): allocp = allocbuf + ${used}`;
|
||||
const areas = (Object.keys(areaLabels) as InteractiveArea[]).filter((area) =>
|
||||
symbols.some((symbol) => symbol.area === area),
|
||||
);
|
||||
return (
|
||||
<div className="memory-react" aria-label={`Interaktywny diagram: ${title}`}>
|
||||
<header>
|
||||
<div>
|
||||
<small>INTERAKTYWNY MODEL PAMIĘCI</small>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<div className="memory-actions">
|
||||
<button aria-pressed={stage === 0} onClick={() => setStage(0)}>
|
||||
definicje / reset
|
||||
</button>
|
||||
{allocations.map((size, index) => (
|
||||
<button
|
||||
key={`${size}-${index}`}
|
||||
aria-pressed={stage === index + 1}
|
||||
onClick={() => setStage(index + 1)}
|
||||
>
|
||||
alloc({size})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<div className="memory-lanes">
|
||||
{areas.map((area) => (
|
||||
<section className={`memory-lane ${area}`} key={area}>
|
||||
<b>{areaLabels[area]}</b>
|
||||
{symbols
|
||||
.filter((symbol) => symbol.area === area)
|
||||
.map((symbol) => (
|
||||
<button
|
||||
key={symbol.id}
|
||||
className={focus === symbol.id ? "active" : ""}
|
||||
onClick={() => setFocus(symbol.id)}
|
||||
>
|
||||
<code>{symbol.declaration}</code>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="arena-wrap">
|
||||
<div className="arena-label">
|
||||
<code>allocbuf[{arenaSize}]</code>
|
||||
<span>
|
||||
użyte {used} B / wolne {arenaSize - used} B
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="arena"
|
||||
style={
|
||||
{
|
||||
"--allocp": used,
|
||||
"--arena-size": arenaSize,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
const segment = allocationIndex(cell);
|
||||
return (
|
||||
<i
|
||||
key={cell}
|
||||
style={
|
||||
segment >= 0
|
||||
? {
|
||||
background:
|
||||
allocationColors[segment % allocationColors.length],
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<span className="allocp">allocp ↑ {used}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="memory-stage">
|
||||
<strong>
|
||||
{stage + 1}/{allocations.length + 1}
|
||||
</strong>{" "}
|
||||
<code>{stageText}</code>
|
||||
</p>
|
||||
{focused && (
|
||||
<aside className="memory-inspector">
|
||||
<strong>{focused.id}</strong>
|
||||
<code>{focused.declaration}</code>
|
||||
<span>{focused.description}</span>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveUmlSequence({
|
||||
config,
|
||||
asset,
|
||||
}: {
|
||||
config: InteractiveAsset;
|
||||
asset: Asset;
|
||||
}) {
|
||||
const stages = config.stages ?? [];
|
||||
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
|
||||
const [stageIndex, setStageIndex] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
stages.length - 1,
|
||||
Number(localStorage.getItem(storageKey) ?? 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
useEffect(
|
||||
() => localStorage.setItem(storageKey, String(stageIndex)),
|
||||
[stageIndex, storageKey],
|
||||
);
|
||||
const stage = stages[stageIndex];
|
||||
return (
|
||||
<div className="uml-react">
|
||||
<header>
|
||||
<div>
|
||||
<small>KANONICZNY DIAGRAM UML · PLANTUML</small>
|
||||
<h3>Reset i dwa przydziały liniowe</h3>
|
||||
</div>
|
||||
<div className="uml-actions">
|
||||
{stages.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
aria-pressed={stageIndex === index}
|
||||
onClick={() => setStageIndex(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<a
|
||||
className="uml-canvas"
|
||||
href={asset.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<img src={asset.href} alt={asset.alt} />
|
||||
</a>
|
||||
{stage && (
|
||||
<aside className="uml-stage">
|
||||
<strong>{stage.label}</strong>
|
||||
<span>{stage.description}</span>
|
||||
{stage.code_ref && <code>{stage.code_ref}</code>}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
|
||||
const interactiveKind = asset.interactive?.kind;
|
||||
const isInteractive = Boolean(interactiveKind);
|
||||
return (
|
||||
<figure
|
||||
className={`card-asset asset-${asset.kind} ${isInteractive ? "asset-interactive" : ""}`}
|
||||
id={asset.label.replace(":", "-")}
|
||||
>
|
||||
{interactiveKind === "uml-sequence" ? (
|
||||
<InteractiveUmlSequence config={asset.interactive!} asset={asset} />
|
||||
) : interactiveKind === "allocator-memory-flow" ? (
|
||||
<>
|
||||
<InteractiveMemoryFlow
|
||||
config={asset.interactive!}
|
||||
title={asset.caption}
|
||||
/>
|
||||
<img
|
||||
className="asset-static-fallback"
|
||||
src={asset.href}
|
||||
alt={asset.alt}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<a href={asset.href} target="_blank" rel="noopener">
|
||||
<img
|
||||
src={asset.href}
|
||||
alt={asset.alt}
|
||||
loading="lazy"
|
||||
style={{ width: `${asset.width * 100}%` }}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{asset.source_href && (
|
||||
<a
|
||||
className="asset-source-link"
|
||||
href={asset.source_href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Źródło PlantUML
|
||||
</a>
|
||||
)}
|
||||
<figcaption>
|
||||
Rys. {number}. {asset.caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function StepMap({
|
||||
steps,
|
||||
progress,
|
||||
onCycle,
|
||||
}: {
|
||||
steps: Section["steps"];
|
||||
progress: Progress | null;
|
||||
onCycle: (id: string, status: Status) => void;
|
||||
}) {
|
||||
if (!steps.length) return null;
|
||||
const labels: Record<Status, string> = {
|
||||
not_started: "○ do omówienia",
|
||||
discussed: "◐ omówione",
|
||||
completed: "● wykonane",
|
||||
};
|
||||
return (
|
||||
<div className="step-map">
|
||||
{steps.map((step) => {
|
||||
const status: Status =
|
||||
progress?.items?.[step.id]?.status ?? "not_started";
|
||||
return (
|
||||
<div
|
||||
className={`step-row ${status === "completed" ? "lesson-progress-completed" : ""}`}
|
||||
key={step.id}
|
||||
>
|
||||
<button
|
||||
className="lesson-progress-control"
|
||||
data-status={status}
|
||||
onClick={() => onCycle(step.id, status)}
|
||||
disabled={!progress}
|
||||
>
|
||||
{labels[status]}
|
||||
</button>
|
||||
<span className="step-title">{step.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionPage({
|
||||
section,
|
||||
progress,
|
||||
onCycle,
|
||||
figureOffset,
|
||||
}: {
|
||||
section: Section;
|
||||
progress: Progress | null;
|
||||
onCycle: (id: string, status: Status) => void;
|
||||
figureOffset: number;
|
||||
}) {
|
||||
return (
|
||||
<Sheet
|
||||
header={section.header_html}
|
||||
left={section.left_margin_html}
|
||||
right={section.right_margin_html}
|
||||
className="section-sheet"
|
||||
>
|
||||
<div id={section.id} className="section-anchor" />
|
||||
<h2 className="section-heading">
|
||||
<span className="section-index">{section.index}</span>
|
||||
<span className="section-title">{section.title}</span>
|
||||
</h2>
|
||||
<StepMap steps={section.steps} progress={progress} onCycle={onCycle} />
|
||||
{section.content_html && <Html html={section.content_html} />}
|
||||
<div className="tasks">
|
||||
{section.tasks.map((task) => (
|
||||
<TaskFlow key={task.ref} task={task} />
|
||||
))}
|
||||
</div>
|
||||
{section.assets.map((asset, index) => (
|
||||
<AssetFigure
|
||||
key={asset.label}
|
||||
asset={asset}
|
||||
number={figureOffset + index + 1}
|
||||
/>
|
||||
))}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressDock({
|
||||
progress,
|
||||
error,
|
||||
}: {
|
||||
progress: Progress | null;
|
||||
error: string;
|
||||
}) {
|
||||
if (!progress?.summary && !error) return null;
|
||||
const counts = progress?.summary.counts;
|
||||
return (
|
||||
<aside className="lesson-progress-dock">
|
||||
<strong>Przebieg zajęć</strong>
|
||||
{counts && (
|
||||
<p>
|
||||
Wykonane: {counts.completed}/{progress!.summary.total} · omówione:{" "}
|
||||
{counts.discussed}/{progress!.summary.total}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="lesson-progress-error">{error}</p>}
|
||||
<a href="/api/report/teams.md" target="_blank" rel="noopener">
|
||||
Otwórz raport do Teams
|
||||
</a>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function App({ model }: { model: CardModel }) {
|
||||
const [scale, setScaleState] = useState(2.18);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [progressError, setProgressError] = useState("");
|
||||
const setScale = (value: number) =>
|
||||
setScaleState(Math.max(0.25, Math.min(3, value)));
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--viewer-canvas-scale",
|
||||
scale.toFixed(3),
|
||||
);
|
||||
}, [scale]);
|
||||
useEffect(() => {
|
||||
fetch("/api/progress")
|
||||
.then((response) =>
|
||||
response.ok
|
||||
? response.json()
|
||||
: Promise.reject(new Error(`HTTP ${response.status}`)),
|
||||
)
|
||||
.then((data) => setProgress(data.progress))
|
||||
.catch((error) =>
|
||||
setProgressError(
|
||||
`Postęp działa tylko przez serwer karty (${error.message}).`,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const wheel = (event: WheelEvent) => {
|
||||
if (!event.altKey || event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
setScaleState((current) =>
|
||||
Math.max(
|
||||
0.25,
|
||||
Math.min(
|
||||
3,
|
||||
current *
|
||||
Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
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",
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ status: next[current] }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setProgress((await response.json()).progress);
|
||||
setProgressError("");
|
||||
} catch (error) {
|
||||
setProgressError(
|
||||
`Nie zapisano postępu: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
let figureCount = 0;
|
||||
return (
|
||||
<>
|
||||
<Topbar toc={model.toc} scale={scale} setScale={setScale} />
|
||||
<main className="paper">
|
||||
<FrontPage front={model.front} />
|
||||
{model.sections.map((section) => {
|
||||
const offset = figureCount;
|
||||
figureCount += section.assets.length;
|
||||
return (
|
||||
<SectionPage
|
||||
key={section.id}
|
||||
section={section}
|
||||
progress={progress}
|
||||
onCycle={cycle}
|
||||
figureOffset={offset}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Sheet
|
||||
header={model.dictionary.header_html}
|
||||
className="dictionary-sheet"
|
||||
>
|
||||
<h2>Słownik WE/EN/EK/KW</h2>
|
||||
<Html html={model.dictionary.content_html} />
|
||||
</Sheet>
|
||||
</main>
|
||||
<ProgressDock progress={progress} error={progressError} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
try {
|
||||
const response = await fetch("./card-data.json", { cache: "no-store" });
|
||||
if (!response.ok)
|
||||
throw new Error(`Nie można wczytać card-data.json: ${response.status}`);
|
||||
const model = (await response.json()) as CardModel;
|
||||
root.render(<App model={model} />);
|
||||
} catch (error) {
|
||||
root.render(
|
||||
<main className="app-load-error">
|
||||
<h1>Nie udało się uruchomić karty</h1>
|
||||
<pre>{error instanceof Error ? error.message : String(error)}</pre>
|
||||
</main>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void start();
|
||||
@@ -0,0 +1,328 @@
|
||||
.memory-react {
|
||||
border: 1px solid #555;
|
||||
background: #fff;
|
||||
font:
|
||||
14px/1.35 system-ui,
|
||||
sans-serif;
|
||||
padding: 10px;
|
||||
color: #1b1b1b;
|
||||
}
|
||||
.memory-react > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #bbb;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
.memory-react > header small {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: #555;
|
||||
font:
|
||||
700 10px/1.2 ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.memory-react h3 {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
.memory-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.memory-actions button {
|
||||
border: 1px solid #777;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.memory-actions button[aria-pressed="true"] {
|
||||
background: #dff3fb;
|
||||
border-color: #237da1;
|
||||
font-weight: 700;
|
||||
}
|
||||
.memory-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.memory-lane {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 3px;
|
||||
border: 1px solid #999;
|
||||
background: #fafafa;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
.memory-lane.compile {
|
||||
background: #fff8dc;
|
||||
}
|
||||
.memory-lane.bss {
|
||||
background: #edf7fb;
|
||||
}
|
||||
.memory-lane.stack {
|
||||
background: #f3f8ef;
|
||||
}
|
||||
.memory-lane.registers {
|
||||
background: #f4f4f4;
|
||||
}
|
||||
.memory-lane.text {
|
||||
background: #f7f0fa;
|
||||
}
|
||||
.memory-lane > b {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.memory-lane button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
padding: 3px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.memory-lane button:hover,
|
||||
.memory-lane button.active {
|
||||
border-color: #238bb7;
|
||||
background: #fff;
|
||||
}
|
||||
.memory-lane code {
|
||||
font-size: 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
.arena-wrap {
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.arena-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.arena {
|
||||
--allocp: 0;
|
||||
--arena-size: 64;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--arena-size), 1fr);
|
||||
height: 40px;
|
||||
border: 1px solid #222;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.arena i {
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
.arena .allocp {
|
||||
position: absolute;
|
||||
left: calc(var(--allocp) / var(--arena-size) * 100%);
|
||||
top: 43px;
|
||||
transform: translateX(-50%);
|
||||
color: #087da8;
|
||||
font:
|
||||
700 11px/1.2 ui-monospace,
|
||||
monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-stage {
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
.memory-inspector {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 2px 10px;
|
||||
margin-top: 7px;
|
||||
padding: 7px;
|
||||
border-left: 3px solid #238bb7;
|
||||
background: #f5fbfd;
|
||||
}
|
||||
.memory-inspector strong {
|
||||
grid-row: 1/3;
|
||||
}
|
||||
.memory-inspector code {
|
||||
font-size: 11px;
|
||||
}
|
||||
.memory-inspector span {
|
||||
font-size: 12px;
|
||||
}
|
||||
.asset-static-fallback {
|
||||
display: none;
|
||||
}
|
||||
.uml-react {
|
||||
border: 1px solid #555;
|
||||
background: #fff;
|
||||
font:
|
||||
13px/1.35 system-ui,
|
||||
sans-serif;
|
||||
color: #1b1b1b;
|
||||
}
|
||||
.uml-react > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #bbb;
|
||||
background: #fafafa;
|
||||
}
|
||||
.uml-react > header small {
|
||||
display: block;
|
||||
color: #555;
|
||||
font:
|
||||
700 10px/1.2 ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.uml-react h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.uml-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.uml-actions button {
|
||||
border: 1px solid #777;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.uml-actions button[aria-pressed="true"] {
|
||||
border-color: #315f78;
|
||||
background: #eaf4fa;
|
||||
font-weight: 700;
|
||||
}
|
||||
.uml-canvas {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.uml-canvas img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 760px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.uml-stage {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 10px;
|
||||
margin: 0 8px 8px;
|
||||
padding: 8px;
|
||||
border-left: 3px solid #315f78;
|
||||
background: #eef7fb;
|
||||
}
|
||||
.uml-stage strong {
|
||||
grid-row: 1/3;
|
||||
}
|
||||
.uml-stage code {
|
||||
grid-column: 2;
|
||||
font-size: 11px;
|
||||
}
|
||||
.asset-source-link {
|
||||
display: inline-block;
|
||||
margin: 5px 0;
|
||||
color: #315f78;
|
||||
font:
|
||||
600 11px/1.3 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.lesson-progress-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 7px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #8c8c8c;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lesson-progress-control:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lesson-progress-control[data-status="discussed"] {
|
||||
background: #fff4cc;
|
||||
border-color: #9b6a00;
|
||||
}
|
||||
.lesson-progress-control[data-status="completed"] {
|
||||
background: #e7f6ed;
|
||||
border-color: #18794e;
|
||||
}
|
||||
.lesson-progress-dock {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 30;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
border: 1px solid #555;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
box-shadow: 0 4px 18px #0003;
|
||||
font:
|
||||
14px/1.35 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.lesson-progress-dock p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.lesson-progress-error {
|
||||
color: #9b1c1c;
|
||||
}
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.section-anchor {
|
||||
position: relative;
|
||||
top: -45px;
|
||||
}
|
||||
.app-load-error {
|
||||
max-width: 760px;
|
||||
margin: 10vh auto;
|
||||
padding: 24px;
|
||||
border: 1px solid #9b1c1c;
|
||||
background: #fff;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.app-load-error pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.memory-lanes {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.memory-react > header,
|
||||
.uml-react > header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.memory-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.memory-react {
|
||||
display: none !important;
|
||||
}
|
||||
.asset-static-fallback {
|
||||
display: block !important;
|
||||
width: 100%;
|
||||
}
|
||||
.uml-actions,
|
||||
.uml-stage,
|
||||
.asset-source-link,
|
||||
.lesson-progress-dock,
|
||||
.lesson-progress-control {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user