handzon-core 0.6.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.
- package/package.json +74 -0
- package/src/collections.ts +150 -0
- package/src/components/Footer.astro +85 -0
- package/src/components/Navbar.astro +74 -0
- package/src/components/Progress.tsx +36 -0
- package/src/components/Sidebar.astro +162 -0
- package/src/components/StepNav.astro +107 -0
- package/src/components/ai/ByokSetup.tsx +90 -0
- package/src/components/ai/ChatButton.tsx +30 -0
- package/src/components/ai/ChatPanel.tsx +244 -0
- package/src/components/auth/SignInButton.astro +41 -0
- package/src/components/auth/UserMenu.astro +79 -0
- package/src/components/auth/UserMenu.tsx +136 -0
- package/src/components/home/FilterBar.tsx +152 -0
- package/src/components/home/Hero.astro +60 -0
- package/src/components/home/Pagination.tsx +89 -0
- package/src/components/home/ResumeRail.tsx +50 -0
- package/src/components/home/TutorialCard.astro +185 -0
- package/src/components/mdx/Callout.astro +77 -0
- package/src/components/mdx/Checkpoint.astro +14 -0
- package/src/components/mdx/Checkpoint.tsx +49 -0
- package/src/components/mdx/Diff.astro +6 -0
- package/src/components/mdx/Diff.tsx +100 -0
- package/src/components/mdx/Download.astro +37 -0
- package/src/components/mdx/Embed.astro +56 -0
- package/src/components/mdx/File.astro +28 -0
- package/src/components/mdx/FileTree.astro +6 -0
- package/src/components/mdx/FileTree.tsx +71 -0
- package/src/components/mdx/Hint.astro +51 -0
- package/src/components/mdx/Mermaid.astro +6 -0
- package/src/components/mdx/Mermaid.tsx +47 -0
- package/src/components/mdx/Playground.astro +6 -0
- package/src/components/mdx/Playground.tsx +34 -0
- package/src/components/mdx/Quiz.astro +6 -0
- package/src/components/mdx/Quiz.tsx +102 -0
- package/src/components/mdx/Recap.astro +65 -0
- package/src/components/mdx/Reveal.astro +7 -0
- package/src/components/mdx/Reveal.tsx +25 -0
- package/src/components/mdx/Step.astro +12 -0
- package/src/components/mdx/Steps.astro +40 -0
- package/src/components/mdx/Tab.astro +22 -0
- package/src/components/mdx/Tabs.astro +67 -0
- package/src/components/mdx/Terminal.astro +6 -0
- package/src/components/mdx/Terminal.tsx +47 -0
- package/src/index.ts +55 -0
- package/src/layouts/BaseLayout.astro +112 -0
- package/src/layouts/TutorialLayout.astro +218 -0
- package/src/lib/ai/client.ts +92 -0
- package/src/lib/ai/context.ts +97 -0
- package/src/lib/content.ts +73 -0
- package/src/lib/mdx-components.ts +47 -0
- package/src/lib/progress/local.ts +89 -0
- package/src/lib/progress/remote.ts +199 -0
- package/src/lib/progress/types.ts +63 -0
- package/src/lib/progress/useProgress.ts +117 -0
- package/src/lib/rehype-mermaid-passthrough.ts +31 -0
- package/src/pages/Home.astro +408 -0
- package/src/pages/TutorialLanding.astro +324 -0
- package/src/pages/TutorialStep.astro +67 -0
- package/src/pages/paths.ts +36 -0
- package/src/server/auth/config.ts +102 -0
- package/src/server/auth/schema.ts +66 -0
- package/src/server/auth/session.ts +27 -0
- package/src/server/auth.ts +127 -0
- package/src/server/db/client.ts +14 -0
- package/src/server/db/migrate.ts +29 -0
- package/src/server/db/schema.ts +65 -0
- package/src/server/handlers/healthz.ts +6 -0
- package/src/server/handlers/progress.ts +90 -0
- package/src/server/handlers/tutorialStats.ts +67 -0
- package/src/server/http.ts +33 -0
- package/src/types/ai.ts +17 -0
- package/styles/base.css +127 -0
- package/styles/components/a11y.css +12 -0
- package/styles/components/byok.css +50 -0
- package/styles/components/chat.css +304 -0
- package/styles/components/checkpoint.css +49 -0
- package/styles/components/diff.css +44 -0
- package/styles/components/expressive-code.css +61 -0
- package/styles/components/filetree.css +68 -0
- package/styles/components/mermaid.css +19 -0
- package/styles/components/modal.css +25 -0
- package/styles/components/progress.css +19 -0
- package/styles/components/quiz.css +101 -0
- package/styles/components/reveal.css +25 -0
- package/styles/components/tabs.css +60 -0
- package/styles/components/terminal.css +55 -0
- package/styles/components.css +28 -0
- package/styles/global.css +15 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { diffLines } from "diff";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
before: string;
|
|
6
|
+
after: string;
|
|
7
|
+
lang?: string;
|
|
8
|
+
layout?: "side-by-side" | "unified";
|
|
9
|
+
beforeLabel?: string;
|
|
10
|
+
afterLabel?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function Diff({
|
|
14
|
+
before,
|
|
15
|
+
after,
|
|
16
|
+
layout: initialLayout = "side-by-side",
|
|
17
|
+
beforeLabel = "Before",
|
|
18
|
+
afterLabel = "After",
|
|
19
|
+
}: Props) {
|
|
20
|
+
const [layout, setLayout] = useState(initialLayout);
|
|
21
|
+
const parts = useMemo(() => diffLines(before, after), [before, after]);
|
|
22
|
+
|
|
23
|
+
if (layout === "unified") {
|
|
24
|
+
return (
|
|
25
|
+
<div className="diff">
|
|
26
|
+
<div className="diff-bar">
|
|
27
|
+
<button type="button" onClick={() => setLayout("side-by-side")}>
|
|
28
|
+
Side-by-side
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
<pre className="diff-body">
|
|
32
|
+
{parts.map((part, i) => (
|
|
33
|
+
<span
|
|
34
|
+
key={i}
|
|
35
|
+
className={part.added ? "diff-add" : part.removed ? "diff-del" : "diff-ctx"}
|
|
36
|
+
>
|
|
37
|
+
{part.added ? "+ " : part.removed ? "- " : " "}
|
|
38
|
+
{part.value}
|
|
39
|
+
</span>
|
|
40
|
+
))}
|
|
41
|
+
</pre>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const leftLines: string[] = [];
|
|
47
|
+
const rightLines: string[] = [];
|
|
48
|
+
for (const p of parts) {
|
|
49
|
+
const lines = p.value.split("\n");
|
|
50
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
51
|
+
if (p.added) {
|
|
52
|
+
for (const l of lines) {
|
|
53
|
+
leftLines.push("");
|
|
54
|
+
rightLines.push(`+ ${l}`);
|
|
55
|
+
}
|
|
56
|
+
} else if (p.removed) {
|
|
57
|
+
for (const l of lines) {
|
|
58
|
+
leftLines.push(`- ${l}`);
|
|
59
|
+
rightLines.push("");
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
for (const l of lines) {
|
|
63
|
+
leftLines.push(` ${l}`);
|
|
64
|
+
rightLines.push(` ${l}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="diff">
|
|
71
|
+
<div className="diff-bar">
|
|
72
|
+
<button type="button" onClick={() => setLayout("unified")}>
|
|
73
|
+
Unified
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="diff-grid">
|
|
77
|
+
<div className="diff-col">
|
|
78
|
+
<div className="diff-label">{beforeLabel}</div>
|
|
79
|
+
<pre>
|
|
80
|
+
{leftLines.map((l, i) => (
|
|
81
|
+
<div key={i} className={l.startsWith("- ") ? "diff-del" : "diff-ctx"}>
|
|
82
|
+
{l || " "}
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</pre>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="diff-col">
|
|
88
|
+
<div className="diff-label">{afterLabel}</div>
|
|
89
|
+
<pre>
|
|
90
|
+
{rightLines.map((l, i) => (
|
|
91
|
+
<div key={i} className={l.startsWith("+ ") ? "diff-add" : "diff-ctx"}>
|
|
92
|
+
{l || " "}
|
|
93
|
+
</div>
|
|
94
|
+
))}
|
|
95
|
+
</pre>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
href: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
size?: string;
|
|
6
|
+
}
|
|
7
|
+
const { href, label, size } = Astro.props;
|
|
8
|
+
const filename = label ?? href.split("/").pop() ?? "download";
|
|
9
|
+
---
|
|
10
|
+
<a class="download" href={href} download>
|
|
11
|
+
<span class="download-icon" aria-hidden="true">⬇</span>
|
|
12
|
+
<span class="download-name">{filename}</span>
|
|
13
|
+
{size && <span class="download-size">{size}</span>}
|
|
14
|
+
</a>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.download {
|
|
18
|
+
display: inline-flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: 0.5rem;
|
|
21
|
+
padding: 0.5rem 0.9rem;
|
|
22
|
+
border: var(--border-default, 2px) solid var(--color-fg);
|
|
23
|
+
background: var(--color-bg);
|
|
24
|
+
color: var(--color-fg);
|
|
25
|
+
font-family: var(--font-mono);
|
|
26
|
+
font-size: 0.9em;
|
|
27
|
+
text-decoration: none;
|
|
28
|
+
transition: transform 0.08s ease, box-shadow 0.08s ease;
|
|
29
|
+
}
|
|
30
|
+
.download:hover {
|
|
31
|
+
transform: translate(-2px, -2px);
|
|
32
|
+
box-shadow: var(--shadow-raised);
|
|
33
|
+
color: var(--color-fg);
|
|
34
|
+
}
|
|
35
|
+
.download-icon { color: var(--color-accent); }
|
|
36
|
+
.download-size { color: var(--color-muted); font-size: 0.85em; margin-left: 0.5rem; }
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
src: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
aspect?: string;
|
|
6
|
+
type?: "iframe" | "video";
|
|
7
|
+
}
|
|
8
|
+
const { src, title = "Embedded content", aspect = "16/9", type = "iframe" } = Astro.props;
|
|
9
|
+
|
|
10
|
+
function normalize(url: string): string {
|
|
11
|
+
try {
|
|
12
|
+
const u = new URL(url, "https://example.com");
|
|
13
|
+
if (u.hostname.endsWith("youtube.com")) {
|
|
14
|
+
u.hostname = "www.youtube-nocookie.com";
|
|
15
|
+
return u.toString();
|
|
16
|
+
}
|
|
17
|
+
return url;
|
|
18
|
+
} catch {
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const finalSrc = type === "iframe" ? normalize(src) : src;
|
|
24
|
+
---
|
|
25
|
+
<div class="embed" style={`aspect-ratio: ${aspect};`}>
|
|
26
|
+
{type === "iframe" ? (
|
|
27
|
+
<iframe
|
|
28
|
+
src={finalSrc}
|
|
29
|
+
title={title}
|
|
30
|
+
loading="lazy"
|
|
31
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
32
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
33
|
+
allowfullscreen
|
|
34
|
+
/>
|
|
35
|
+
) : (
|
|
36
|
+
<video controls preload="metadata">
|
|
37
|
+
<source src={finalSrc} />
|
|
38
|
+
</video>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
.embed {
|
|
44
|
+
margin: 1.5rem 0;
|
|
45
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
46
|
+
background: var(--color-surface);
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
}
|
|
49
|
+
.embed iframe,
|
|
50
|
+
.embed video {
|
|
51
|
+
width: 100%;
|
|
52
|
+
height: 100%;
|
|
53
|
+
border: 0;
|
|
54
|
+
display: block;
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
const { name } = Astro.props;
|
|
6
|
+
---
|
|
7
|
+
<div class="file-label">
|
|
8
|
+
<span class="file-icon" aria-hidden="true">▸</span>
|
|
9
|
+
<span class="file-name">{name}</span>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="file-body"><slot /></div>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
.file-label {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: 0.4rem;
|
|
18
|
+
background: var(--color-surface);
|
|
19
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
20
|
+
border-bottom: none;
|
|
21
|
+
padding: 0.3rem 0.6rem;
|
|
22
|
+
font-family: var(--font-mono);
|
|
23
|
+
font-size: 0.85em;
|
|
24
|
+
color: var(--color-muted);
|
|
25
|
+
}
|
|
26
|
+
.file-icon { color: var(--color-accent); }
|
|
27
|
+
.file-body :global(pre) { margin-top: 0 !important; }
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { File as FileIcon, Folder, FolderOpen } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
type Node = { name: string; children?: Node[] };
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
paths?: string[];
|
|
8
|
+
tree?: Node[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pathsToTree(paths: string[]): Node[] {
|
|
12
|
+
const root: Node = { name: "", children: [] };
|
|
13
|
+
for (const path of paths) {
|
|
14
|
+
const parts = path.split("/").filter(Boolean);
|
|
15
|
+
let cursor = root;
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
cursor.children ??= [];
|
|
18
|
+
let child = cursor.children.find((c) => c.name === part);
|
|
19
|
+
if (!child) {
|
|
20
|
+
child = { name: part };
|
|
21
|
+
cursor.children.push(child);
|
|
22
|
+
}
|
|
23
|
+
cursor = child;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return root.children ?? [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isFolder(node: Node): boolean {
|
|
30
|
+
return Array.isArray(node.children) && node.children.length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function NodeRow({ node }: { node: Node }) {
|
|
34
|
+
const [open, setOpen] = useState(true);
|
|
35
|
+
const folder = isFolder(node);
|
|
36
|
+
return (
|
|
37
|
+
<li className="ft-row">
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
className="ft-btn"
|
|
41
|
+
onClick={() => folder && setOpen((o) => !o)}
|
|
42
|
+
aria-expanded={folder ? open : undefined}
|
|
43
|
+
>
|
|
44
|
+
{folder ? open ? <FolderOpen size={13} /> : <Folder size={13} /> : <FileIcon size={13} />}
|
|
45
|
+
<span className="ft-name">{node.name}</span>
|
|
46
|
+
</button>
|
|
47
|
+
{folder && open && node.children && (
|
|
48
|
+
<ul className="ft-list">
|
|
49
|
+
{node.children.map((child) => (
|
|
50
|
+
// Indentation comes from .ft-list CSS (padding-left + guide
|
|
51
|
+
// line), not an inline marginLeft. Compounding the inline
|
|
52
|
+
// offset with the browser-default UL padding produced the
|
|
53
|
+
// huge stair-step in the screenshot.
|
|
54
|
+
<NodeRow key={child.name} node={child} />
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
)}
|
|
58
|
+
</li>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function FileTree({ paths, tree }: Props) {
|
|
63
|
+
const resolved = tree ?? (paths ? pathsToTree(paths) : []);
|
|
64
|
+
return (
|
|
65
|
+
<ul className="ft-root">
|
|
66
|
+
{resolved.map((node) => (
|
|
67
|
+
<NodeRow key={node.name} node={node} />
|
|
68
|
+
))}
|
|
69
|
+
</ul>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
title?: string;
|
|
4
|
+
}
|
|
5
|
+
const { title = "Show hint" } = Astro.props;
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<details class="hint">
|
|
9
|
+
<summary>
|
|
10
|
+
<svg class="hint-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
11
|
+
<path d="M9 18h6"></path>
|
|
12
|
+
<path d="M10 22h4"></path>
|
|
13
|
+
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"></path>
|
|
14
|
+
</svg>
|
|
15
|
+
<span>{title}</span>
|
|
16
|
+
</summary>
|
|
17
|
+
<div class="hint-body"><slot /></div>
|
|
18
|
+
</details>
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
.hint {
|
|
22
|
+
margin: 1rem 0;
|
|
23
|
+
background: var(--color-surface);
|
|
24
|
+
border: var(--border-default) solid var(--color-border);
|
|
25
|
+
border-radius: 0;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
}
|
|
28
|
+
.hint > summary {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 0.5rem;
|
|
32
|
+
padding: 0.65rem 0.9rem;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
font-weight: 500;
|
|
35
|
+
color: var(--color-accent);
|
|
36
|
+
list-style: none;
|
|
37
|
+
user-select: none;
|
|
38
|
+
}
|
|
39
|
+
.hint > summary::-webkit-details-marker { display: none; }
|
|
40
|
+
.hint-icon {
|
|
41
|
+
color: var(--color-accent);
|
|
42
|
+
transition: transform 0.15s ease;
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
}
|
|
45
|
+
.hint[open] .hint-icon { transform: rotate(15deg); }
|
|
46
|
+
.hint-body {
|
|
47
|
+
padding: 0.6rem 0.85rem;
|
|
48
|
+
background: var(--color-bg);
|
|
49
|
+
margin: 0 0.5rem 0.5rem;
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
chart: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Interactive mermaid island. The default authoring path is fenced
|
|
10
|
+
* ```mermaid blocks, which rehype-mermaid renders at build time. Use
|
|
11
|
+
* this island only when the diagram needs to respond to runtime state.
|
|
12
|
+
*/
|
|
13
|
+
export default function Mermaid({ chart, id }: Props) {
|
|
14
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const mermaid = (await import("mermaid")).default;
|
|
22
|
+
mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
|
|
23
|
+
const { svg } = await mermaid.render(
|
|
24
|
+
id ?? `mermaid-${Math.random().toString(36).slice(2)}`,
|
|
25
|
+
chart,
|
|
26
|
+
);
|
|
27
|
+
if (!cancelled && ref.current) ref.current.innerHTML = svg;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
};
|
|
35
|
+
}, [chart, id]);
|
|
36
|
+
|
|
37
|
+
if (error) {
|
|
38
|
+
return (
|
|
39
|
+
<pre className="mermaid-error">
|
|
40
|
+
Mermaid render failed: {error}
|
|
41
|
+
{"\n"}
|
|
42
|
+
{chart}
|
|
43
|
+
</pre>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return <div ref={ref} className="mermaid-wrap" />;
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Sandpack, type SandpackPredefinedTemplate } from "@codesandbox/sandpack-react";
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
template?: SandpackPredefinedTemplate;
|
|
5
|
+
files?: Record<string, string | { code: string; hidden?: boolean }>;
|
|
6
|
+
dependencies?: Record<string, string>;
|
|
7
|
+
height?: number;
|
|
8
|
+
showConsole?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function Playground({
|
|
12
|
+
template = "react-ts",
|
|
13
|
+
files,
|
|
14
|
+
dependencies,
|
|
15
|
+
height = 480,
|
|
16
|
+
showConsole = true,
|
|
17
|
+
}: Props) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="playground" style={{ height }}>
|
|
20
|
+
<Sandpack
|
|
21
|
+
template={template}
|
|
22
|
+
files={files}
|
|
23
|
+
customSetup={dependencies ? { dependencies } : undefined}
|
|
24
|
+
theme="dark"
|
|
25
|
+
options={{
|
|
26
|
+
showConsole,
|
|
27
|
+
showConsoleButton: true,
|
|
28
|
+
editorHeight: height,
|
|
29
|
+
editorWidthPercentage: 50,
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Check, X } from "lucide-react";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
|
+
import { useProgress } from "../../lib/progress/useProgress";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
question: string;
|
|
7
|
+
options: string[];
|
|
8
|
+
answer: number | number[];
|
|
9
|
+
explanation?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
multi?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function Quiz({ question, options, answer, explanation, id, multi }: Props) {
|
|
15
|
+
const reactId = useId();
|
|
16
|
+
const questionId = id ?? `quiz:${reactId}:${question.slice(0, 40)}`;
|
|
17
|
+
const { state, recordQuiz } = useProgress();
|
|
18
|
+
|
|
19
|
+
const previous = state.quizzes[questionId];
|
|
20
|
+
const [chosen, setChosen] = useState<number[]>(previous?.chosen ?? []);
|
|
21
|
+
const [submitted, setSubmitted] = useState<boolean>(!!previous);
|
|
22
|
+
|
|
23
|
+
const correctSet = new Set(Array.isArray(answer) ? answer : [answer]);
|
|
24
|
+
const expectMulti = multi ?? Array.isArray(answer);
|
|
25
|
+
|
|
26
|
+
function toggle(i: number) {
|
|
27
|
+
if (submitted) return;
|
|
28
|
+
if (expectMulti) {
|
|
29
|
+
setChosen((c) => (c.includes(i) ? c.filter((x) => x !== i) : [...c, i]));
|
|
30
|
+
} else {
|
|
31
|
+
setChosen([i]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function submit() {
|
|
36
|
+
const sorted = [...chosen].sort((a, b) => a - b);
|
|
37
|
+
const correct = sorted.length === correctSet.size && sorted.every((v) => correctSet.has(v));
|
|
38
|
+
recordQuiz(questionId, sorted, correct);
|
|
39
|
+
setSubmitted(true);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function reset() {
|
|
43
|
+
setChosen([]);
|
|
44
|
+
setSubmitted(false);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<fieldset className="quiz" disabled={submitted}>
|
|
49
|
+
<legend className="quiz-q">{question}</legend>
|
|
50
|
+
<div className="quiz-options">
|
|
51
|
+
{options.map((opt, i) => {
|
|
52
|
+
const isChosen = chosen.includes(i);
|
|
53
|
+
const isCorrect = submitted && correctSet.has(i);
|
|
54
|
+
const wasWrong = submitted && isChosen && !correctSet.has(i);
|
|
55
|
+
return (
|
|
56
|
+
<label
|
|
57
|
+
key={i}
|
|
58
|
+
className={[
|
|
59
|
+
"quiz-opt",
|
|
60
|
+
isChosen ? "is-chosen" : "",
|
|
61
|
+
isCorrect ? "is-correct" : "",
|
|
62
|
+
wasWrong ? "is-wrong" : "",
|
|
63
|
+
].join(" ")}
|
|
64
|
+
>
|
|
65
|
+
<input
|
|
66
|
+
type={expectMulti ? "checkbox" : "radio"}
|
|
67
|
+
name={questionId}
|
|
68
|
+
checked={isChosen}
|
|
69
|
+
onChange={() => toggle(i)}
|
|
70
|
+
/>
|
|
71
|
+
<span
|
|
72
|
+
className={`quiz-opt-toggle quiz-opt-toggle--${expectMulti ? "multi" : "single"}`}
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
>
|
|
75
|
+
{isCorrect ? <Check size={12} /> : wasWrong ? <X size={12} /> : null}
|
|
76
|
+
</span>
|
|
77
|
+
<span>{opt}</span>
|
|
78
|
+
</label>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</div>
|
|
82
|
+
<div className="quiz-actions">
|
|
83
|
+
{!submitted && (
|
|
84
|
+
<button type="button" onClick={submit} disabled={chosen.length === 0}>
|
|
85
|
+
Check
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
{submitted && (
|
|
89
|
+
<button type="button" onClick={reset}>
|
|
90
|
+
Try again
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
{submitted && (
|
|
94
|
+
<span className={previous?.correct ? "quiz-msg ok" : "quiz-msg no"}>
|
|
95
|
+
{previous?.correct ? "Correct" : "Not quite"}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
{submitted && explanation && <p className="quiz-exp">{explanation}</p>}
|
|
100
|
+
</fieldset>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
title?: string;
|
|
4
|
+
items: string[];
|
|
5
|
+
}
|
|
6
|
+
const { title = "What you learned", items } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
<section class="recap">
|
|
9
|
+
<h3 class="recap-title">
|
|
10
|
+
<svg class="recap-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
11
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
12
|
+
</svg>
|
|
13
|
+
<span>{title}</span>
|
|
14
|
+
</h3>
|
|
15
|
+
<ul class="recap-items">
|
|
16
|
+
{items.map((item) => (
|
|
17
|
+
<li>
|
|
18
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
19
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
20
|
+
</svg>
|
|
21
|
+
<span>{item}</span>
|
|
22
|
+
</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.recap {
|
|
29
|
+
margin: 2rem 0;
|
|
30
|
+
padding: 1rem 1.25rem;
|
|
31
|
+
background: var(--color-surface);
|
|
32
|
+
border: var(--border-default) solid var(--color-border);
|
|
33
|
+
border-left: var(--border-thick) solid var(--color-accent);
|
|
34
|
+
border-radius: 0;
|
|
35
|
+
}
|
|
36
|
+
.recap-title {
|
|
37
|
+
margin: 0 0 0.6rem;
|
|
38
|
+
font-size: 0.78rem;
|
|
39
|
+
text-transform: uppercase;
|
|
40
|
+
letter-spacing: 0.08em;
|
|
41
|
+
color: var(--color-accent);
|
|
42
|
+
display: inline-flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 0.4rem;
|
|
45
|
+
}
|
|
46
|
+
.recap-icon { color: var(--color-accent); flex-shrink: 0; }
|
|
47
|
+
.recap-items {
|
|
48
|
+
list-style: none;
|
|
49
|
+
margin: 0;
|
|
50
|
+
padding: 0;
|
|
51
|
+
display: grid;
|
|
52
|
+
gap: 0.3rem;
|
|
53
|
+
}
|
|
54
|
+
.recap-items li {
|
|
55
|
+
display: grid;
|
|
56
|
+
grid-template-columns: 1.1rem 1fr;
|
|
57
|
+
gap: 0.55rem;
|
|
58
|
+
align-items: start;
|
|
59
|
+
margin: 0;
|
|
60
|
+
}
|
|
61
|
+
.recap-items li :global(svg) {
|
|
62
|
+
color: var(--color-accent);
|
|
63
|
+
margin-top: 0.25em;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Eye, EyeOff } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
label?: string;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function Reveal({ label = "Show solution", children }: Props) {
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
return (
|
|
12
|
+
<div className="reveal">
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
onClick={() => setOpen((o) => !o)}
|
|
16
|
+
className="reveal-btn"
|
|
17
|
+
aria-expanded={open}
|
|
18
|
+
>
|
|
19
|
+
{open ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
20
|
+
<span>{open ? "Hide" : label}</span>
|
|
21
|
+
</button>
|
|
22
|
+
{open && <div className="reveal-content">{children}</div>}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|