handzon-core 0.15.3 → 0.16.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 +1 -1
- package/src/components/StepNav.astro +7 -4
- package/src/components/auth/UserMenu.tsx +1 -3
- package/src/components/home/ActiveFilterChips.tsx +16 -0
- package/src/components/home/FilterBar.tsx +37 -6
- package/src/components/home/TutorialCard.astro +55 -0
- package/src/components/mdx/Checkpoint.tsx +10 -23
- package/src/components/mdx/Mermaid.tsx +2 -1
- package/src/components/mdx/Quiz.tsx +6 -2
- package/src/layouts/BaseLayout.astro +27 -5
- package/src/layouts/TutorialLayout.astro +34 -0
- package/src/lib/icons.ts +3 -0
- package/src/lib/mermaid-theme.ts +130 -0
- package/src/lib/progress/stepCompletion.ts +20 -0
- package/src/lib/progress/types.ts +6 -0
- package/src/lib/progress/useProgress.ts +13 -0
- package/src/pages/Home.astro +82 -9
- package/src/pages/TutorialStep.astro +2 -0
- package/styles/components/filterbar.css +29 -0
- package/styles/components/mermaid.css +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -11,17 +11,20 @@ interface Props {
|
|
|
11
11
|
currentStepSlug: string;
|
|
12
12
|
gated: boolean;
|
|
13
13
|
hasCheckpoint: boolean;
|
|
14
|
+
hasQuiz: boolean;
|
|
14
15
|
nextTutorial?: TutorialSummary;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint, nextTutorial } =
|
|
18
|
+
const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint, hasQuiz, nextTutorial } =
|
|
19
|
+
Astro.props;
|
|
18
20
|
const idx = steps.findIndex((s) => parseStepId(s.id).stepSlug === currentStepSlug);
|
|
19
21
|
const prev = idx > 0 ? steps[idx - 1] : null;
|
|
20
22
|
const next = idx >= 0 && idx < steps.length - 1 ? steps[idx + 1] : null;
|
|
21
23
|
const prevSlug = prev ? parseStepId(prev.id).stepSlug : null;
|
|
22
24
|
const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
25
|
+
const hasCompletionItem = hasCheckpoint || hasQuiz;
|
|
23
26
|
---
|
|
24
|
-
<nav class:list={["step-nav", !next && "step-nav-final"]} data-gated={gated &&
|
|
27
|
+
<nav class:list={["step-nav", !next && "step-nav-final"]} data-gated={gated && hasCompletionItem ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
|
|
25
28
|
<div>
|
|
26
29
|
{prev && (
|
|
27
30
|
<a class="sn-prev" href={withBase(`/${tutorialSlug}/${prevSlug}`)}>
|
|
@@ -32,7 +35,7 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
|
32
35
|
<div class="sn-slot">
|
|
33
36
|
{next ? (
|
|
34
37
|
<a class="sn-next" href={withBase(`/${tutorialSlug}/${nextSlug}`)} data-next-link="true">
|
|
35
|
-
{
|
|
38
|
+
{hasCompletionItem ? "Continue" : "Next"}: {next.data.title} →
|
|
36
39
|
</a>
|
|
37
40
|
) : (
|
|
38
41
|
<TutorialCompletion
|
|
@@ -46,7 +49,7 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
|
46
49
|
</nav>
|
|
47
50
|
|
|
48
51
|
<script>
|
|
49
|
-
// Gating: if the host step contains a
|
|
52
|
+
// Gating: if the host step contains a completion item AND tutorial is gated,
|
|
50
53
|
// disable Next until the step is marked complete.
|
|
51
54
|
import { getStore } from "../lib/progress/local";
|
|
52
55
|
const nav = document.querySelector<HTMLElement>(".step-nav");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useState } from "react";
|
|
2
2
|
import { withBase } from "../../lib/base";
|
|
3
|
+
import { GITHUB_ICON_PATH } from "../../lib/icons";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Client-only auth menu. Fetches `/api/auth/session` + `/api/auth/csrf`
|
|
@@ -77,9 +78,6 @@ function clearAuthSnapshot() {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
const GITHUB_ICON_PATH =
|
|
81
|
-
"M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
|
|
82
|
-
|
|
83
81
|
export default function UserMenu() {
|
|
84
82
|
// `undefined` = not yet loaded; `null` = no auth or signed out;
|
|
85
83
|
// object = signed in. Seeded from the last-known snapshot so a
|
|
@@ -5,9 +5,11 @@ interface Props {
|
|
|
5
5
|
q: string;
|
|
6
6
|
levels: Set<string>;
|
|
7
7
|
tags: Set<string>;
|
|
8
|
+
favoritesOnly: boolean;
|
|
8
9
|
onClearQ: () => void;
|
|
9
10
|
onRemoveLevel: (level: string) => void;
|
|
10
11
|
onRemoveTag: (tag: string) => void;
|
|
12
|
+
onClearFavorites: () => void;
|
|
11
13
|
onClearAll: () => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -27,9 +29,11 @@ export default function ActiveFilterChips({
|
|
|
27
29
|
q,
|
|
28
30
|
levels,
|
|
29
31
|
tags,
|
|
32
|
+
favoritesOnly,
|
|
30
33
|
onClearQ,
|
|
31
34
|
onRemoveLevel,
|
|
32
35
|
onRemoveTag,
|
|
36
|
+
onClearFavorites,
|
|
33
37
|
onClearAll,
|
|
34
38
|
}: Props) {
|
|
35
39
|
function chipKey(e: KeyboardEvent<HTMLButtonElement>, remove: () => void) {
|
|
@@ -80,6 +84,18 @@ export default function ActiveFilterChips({
|
|
|
80
84
|
<X size={12} aria-hidden="true" />
|
|
81
85
|
</button>
|
|
82
86
|
))}
|
|
87
|
+
{favoritesOnly && (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="active-filter-chip"
|
|
91
|
+
aria-label="Remove favorites filter"
|
|
92
|
+
onClick={onClearFavorites}
|
|
93
|
+
onKeyDown={(e) => chipKey(e, onClearFavorites)}
|
|
94
|
+
>
|
|
95
|
+
<span className="afc-label">favorites</span>
|
|
96
|
+
<X size={12} aria-hidden="true" />
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
83
99
|
<button type="button" className="active-filter-clear" onClick={onClearAll}>
|
|
84
100
|
Clear all
|
|
85
101
|
</button>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { GraduationCap, Hash, Search } from "lucide-react";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { GraduationCap, Hash, Search, Star } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import MultiSelect, { type MultiSelectOption } from "../ui/MultiSelect.tsx";
|
|
4
4
|
import ActiveFilterChips from "./ActiveFilterChips.tsx";
|
|
5
5
|
import SortBar from "./SortBar.tsx";
|
|
@@ -17,6 +17,7 @@ interface FilterState {
|
|
|
17
17
|
q: string;
|
|
18
18
|
levels: Set<string>;
|
|
19
19
|
tags: Set<string>;
|
|
20
|
+
favoritesOnly: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function parseCsv(value: string | null): Set<string> {
|
|
@@ -31,7 +32,7 @@ function parseCsv(value: string | null): Set<string> {
|
|
|
31
32
|
|
|
32
33
|
function readUrlState(): FilterState {
|
|
33
34
|
if (typeof window === "undefined") {
|
|
34
|
-
return { q: "", levels: new Set(), tags: new Set() };
|
|
35
|
+
return { q: "", levels: new Set(), tags: new Set(), favoritesOnly: false };
|
|
35
36
|
}
|
|
36
37
|
const url = new URL(window.location.href);
|
|
37
38
|
const levels = url.searchParams.get("level")
|
|
@@ -43,6 +44,7 @@ function readUrlState(): FilterState {
|
|
|
43
44
|
q: url.searchParams.get("q") ?? "",
|
|
44
45
|
levels,
|
|
45
46
|
tags: parseCsv(url.searchParams.get("tag")),
|
|
47
|
+
favoritesOnly: url.searchParams.get("favorites") === "1",
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -54,6 +56,8 @@ function writeUrlState(state: FilterState) {
|
|
|
54
56
|
else url.searchParams.delete("level");
|
|
55
57
|
if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
|
|
56
58
|
else url.searchParams.delete("tag");
|
|
59
|
+
if (state.favoritesOnly) url.searchParams.set("favorites", "1");
|
|
60
|
+
else url.searchParams.delete("favorites");
|
|
57
61
|
// Always strip the legacy key so an upgraded URL doesn't carry both
|
|
58
62
|
// shapes. First user interaction "migrates" the link.
|
|
59
63
|
url.searchParams.delete("difficulty");
|
|
@@ -69,7 +73,8 @@ function applyFilters(state: FilterState) {
|
|
|
69
73
|
const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
70
74
|
const cardTags = (card.dataset.tags ?? "").split(",");
|
|
71
75
|
const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
72
|
-
const
|
|
76
|
+
const matchesFavorite = !state.favoritesOnly || card.hasAttribute("data-favorited");
|
|
77
|
+
const show = matchesQ && matchesLevel && matchesTag && matchesFavorite;
|
|
73
78
|
if (show) {
|
|
74
79
|
card.removeAttribute("data-filter-hidden");
|
|
75
80
|
visible += 1;
|
|
@@ -100,12 +105,22 @@ function applyFilters(state: FilterState) {
|
|
|
100
105
|
|
|
101
106
|
export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
|
|
102
107
|
const [state, setState] = useState<FilterState>(readUrlState);
|
|
108
|
+
const stateRef = useRef(state);
|
|
103
109
|
|
|
104
110
|
useEffect(() => {
|
|
111
|
+
stateRef.current = state;
|
|
105
112
|
applyFilters(state);
|
|
106
113
|
writeUrlState(state);
|
|
107
114
|
}, [state]);
|
|
108
115
|
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
function onFavoritesChanged() {
|
|
118
|
+
applyFilters(stateRef.current);
|
|
119
|
+
}
|
|
120
|
+
window.addEventListener("hz:favorites-changed", onFavoritesChanged);
|
|
121
|
+
return () => window.removeEventListener("hz:favorites-changed", onFavoritesChanged);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
109
124
|
function setQ(q: string) {
|
|
110
125
|
setState((prev) => ({ ...prev, q }));
|
|
111
126
|
}
|
|
@@ -115,6 +130,9 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
115
130
|
function setTags(next: Set<string>) {
|
|
116
131
|
setState((prev) => ({ ...prev, tags: next }));
|
|
117
132
|
}
|
|
133
|
+
function setFavoritesOnly(favoritesOnly: boolean) {
|
|
134
|
+
setState((prev) => ({ ...prev, favoritesOnly }));
|
|
135
|
+
}
|
|
118
136
|
function removeLevel(level: string) {
|
|
119
137
|
setState((prev) => {
|
|
120
138
|
const next = new Set(prev.levels);
|
|
@@ -130,7 +148,7 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
130
148
|
});
|
|
131
149
|
}
|
|
132
150
|
function clearAll() {
|
|
133
|
-
setState({ q: "", levels: new Set(), tags: new Set() });
|
|
151
|
+
setState({ q: "", levels: new Set(), tags: new Set(), favoritesOnly: false });
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
|
|
@@ -144,7 +162,8 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
144
162
|
count: tagCounts[t] ?? 0,
|
|
145
163
|
}));
|
|
146
164
|
|
|
147
|
-
const hasActive =
|
|
165
|
+
const hasActive =
|
|
166
|
+
state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0 || state.favoritesOnly;
|
|
148
167
|
|
|
149
168
|
return (
|
|
150
169
|
<div className="filterbar">
|
|
@@ -179,6 +198,16 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
179
198
|
triggerIcon={<Hash size={14} aria-hidden="true" />}
|
|
180
199
|
/>
|
|
181
200
|
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className="favorites-filter"
|
|
204
|
+
aria-pressed={state.favoritesOnly}
|
|
205
|
+
onClick={() => setFavoritesOnly(!state.favoritesOnly)}
|
|
206
|
+
>
|
|
207
|
+
<Star size={14} aria-hidden="true" />
|
|
208
|
+
Favorites
|
|
209
|
+
</button>
|
|
210
|
+
|
|
182
211
|
<span className="fb-divider" aria-hidden="true" />
|
|
183
212
|
<div className="fb-sort-slot">
|
|
184
213
|
<SortBar />
|
|
@@ -190,9 +219,11 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
190
219
|
q={state.q}
|
|
191
220
|
levels={state.levels}
|
|
192
221
|
tags={state.tags}
|
|
222
|
+
favoritesOnly={state.favoritesOnly}
|
|
193
223
|
onClearQ={() => setQ("")}
|
|
194
224
|
onRemoveLevel={removeLevel}
|
|
195
225
|
onRemoveTag={removeTag}
|
|
226
|
+
onClearFavorites={() => setFavoritesOnly(false)}
|
|
196
227
|
onClearAll={clearAll}
|
|
197
228
|
/>
|
|
198
229
|
)}
|
|
@@ -37,6 +37,30 @@ const defaultCover = getDefaultCoverMeta({
|
|
|
37
37
|
data-card-slug={slug}
|
|
38
38
|
data-popularity="0"
|
|
39
39
|
>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
class="card-fav"
|
|
43
|
+
data-fav-slug={slug}
|
|
44
|
+
aria-pressed="false"
|
|
45
|
+
aria-label={`Add ${data.title} to favorites`}
|
|
46
|
+
>
|
|
47
|
+
<svg
|
|
48
|
+
viewBox="0 0 24 24"
|
|
49
|
+
width="18"
|
|
50
|
+
height="18"
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
stroke-width="2"
|
|
54
|
+
stroke-linecap="round"
|
|
55
|
+
stroke-linejoin="round"
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
>
|
|
58
|
+
<path
|
|
59
|
+
d="M11.48 3.5a.59.59 0 0 1 1.04 0l2.38 4.82a.59.59 0 0 0 .44.32l5.32.77a.59.59 0 0 1 .33 1l-3.85 3.75a.59.59 0 0 0-.17.52l.91 5.3a.59.59 0 0 1-.86.62l-4.76-2.5a.59.59 0 0 0-.55 0l-4.76 2.5a.59.59 0 0 1-.86-.62l.91-5.3a.59.59 0 0 0-.17-.52L3.01 10.4a.59.59 0 0 1 .33-1l5.32-.77a.59.59 0 0 0 .44-.32l2.38-4.82Z"
|
|
60
|
+
/>
|
|
61
|
+
</svg>
|
|
62
|
+
<span class="sr-only">Favorite</span>
|
|
63
|
+
</button>
|
|
40
64
|
<a href={withBase(`/${slug}`)} class="card-link">
|
|
41
65
|
<div class="card-media">
|
|
42
66
|
{
|
|
@@ -151,6 +175,7 @@ const defaultCover = getDefaultCoverMeta({
|
|
|
151
175
|
border: 1px solid var(--color-border);
|
|
152
176
|
background: var(--color-bg);
|
|
153
177
|
display: flex;
|
|
178
|
+
position: relative;
|
|
154
179
|
transition:
|
|
155
180
|
border-color 0.12s ease,
|
|
156
181
|
transform 0.12s ease;
|
|
@@ -166,6 +191,36 @@ const defaultCover = getDefaultCoverMeta({
|
|
|
166
191
|
flex-direction: column;
|
|
167
192
|
width: 100%;
|
|
168
193
|
}
|
|
194
|
+
.card-fav {
|
|
195
|
+
position: absolute;
|
|
196
|
+
top: 0.35rem;
|
|
197
|
+
right: 0.35rem;
|
|
198
|
+
z-index: 5;
|
|
199
|
+
display: inline-grid;
|
|
200
|
+
place-items: center;
|
|
201
|
+
width: 2rem;
|
|
202
|
+
height: 2rem;
|
|
203
|
+
padding: 0;
|
|
204
|
+
border: 0;
|
|
205
|
+
background: transparent;
|
|
206
|
+
color: var(--color-muted);
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
transition:
|
|
209
|
+
color 0.12s ease,
|
|
210
|
+
transform 0.12s ease;
|
|
211
|
+
}
|
|
212
|
+
.card-fav:hover,
|
|
213
|
+
.card-fav:focus-visible {
|
|
214
|
+
color: var(--color-fg);
|
|
215
|
+
outline: none;
|
|
216
|
+
transform: scale(1.04);
|
|
217
|
+
}
|
|
218
|
+
.card-fav[aria-pressed="true"] {
|
|
219
|
+
color: #fff;
|
|
220
|
+
}
|
|
221
|
+
.card-fav[aria-pressed="true"] svg {
|
|
222
|
+
fill: currentColor;
|
|
223
|
+
}
|
|
169
224
|
.card-media {
|
|
170
225
|
aspect-ratio: 16 / 9;
|
|
171
226
|
border-bottom: 1px solid var(--color-border);
|
|
@@ -15,34 +15,19 @@ interface Props {
|
|
|
15
15
|
id?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* Reads the host tutorial/step from the page-level route marker
|
|
20
|
-
* (<div id="tt-route" data-tutorial-slug=... data-step-slug=...>) that
|
|
21
|
-
* TutorialLayout emits. Looking it up here keeps the Astro wrapper trivial.
|
|
22
|
-
*/
|
|
23
|
-
function useRoute() {
|
|
24
|
-
const [route, setRoute] = useState<{ tutorial: string; step: string } | null>(null);
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
const el = document.getElementById("tt-route");
|
|
27
|
-
if (!el) return;
|
|
28
|
-
const tutorial = el.dataset.tutorialSlug;
|
|
29
|
-
const step = el.dataset.stepSlug;
|
|
30
|
-
if (tutorial && step) setRoute({ tutorial, step });
|
|
31
|
-
}, []);
|
|
32
|
-
return route;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
18
|
export default function Checkpoint({ label, id }: Props) {
|
|
36
19
|
const reactId = useId();
|
|
37
20
|
const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
|
|
38
|
-
const { state, recordCheckpoint, removeCheckpoint
|
|
39
|
-
useProgress();
|
|
40
|
-
const route = useRoute();
|
|
21
|
+
const { state, recordCheckpoint, removeCheckpoint } = useProgress();
|
|
41
22
|
const done = !!state.checkpoints[checkpointId];
|
|
42
23
|
const aiEnabled = useAiEnabled();
|
|
43
24
|
const [stuck, setStuck] = useState(false);
|
|
44
25
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
45
26
|
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
document.dispatchEvent(new CustomEvent("hz:step-item"));
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
46
31
|
// Show the "Stuck?" nudge after STUCK_DELAY_MS of an unchecked
|
|
47
32
|
// checkpoint being on-screen. Resets the timer if the checkpoint
|
|
48
33
|
// scrolls back off screen. Fires once — once shown, stays shown
|
|
@@ -75,11 +60,9 @@ export default function Checkpoint({ label, id }: Props) {
|
|
|
75
60
|
function onToggle() {
|
|
76
61
|
if (done) {
|
|
77
62
|
removeCheckpoint(checkpointId);
|
|
78
|
-
if (route) markStepIncomplete(route.tutorial, route.step);
|
|
79
63
|
return;
|
|
80
64
|
}
|
|
81
65
|
recordCheckpoint(checkpointId);
|
|
82
|
-
if (route) markStepComplete(route.tutorial, route.step);
|
|
83
66
|
}
|
|
84
67
|
|
|
85
68
|
// Family D: inline failure feedback from a submit_verification call.
|
|
@@ -91,7 +74,11 @@ export default function Checkpoint({ label, id }: Props) {
|
|
|
91
74
|
const showFeedback = !done && feedback && !feedback.pass;
|
|
92
75
|
|
|
93
76
|
return (
|
|
94
|
-
<div
|
|
77
|
+
<div
|
|
78
|
+
ref={rootRef}
|
|
79
|
+
className={done ? "checkpoint is-done" : "checkpoint"}
|
|
80
|
+
data-checkpoint-id={checkpointId}
|
|
81
|
+
>
|
|
95
82
|
<button type="button" onClick={onToggle} aria-pressed={done}>
|
|
96
83
|
<span className="checkpoint-box">{done && <Check size={16} />}</span>
|
|
97
84
|
<span>{label}</span>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { buildMermaidConfig } from "../../lib/mermaid-theme.ts";
|
|
2
3
|
|
|
3
4
|
interface Props {
|
|
4
5
|
chart: string;
|
|
@@ -19,7 +20,7 @@ export default function Mermaid({ chart, id }: Props) {
|
|
|
19
20
|
(async () => {
|
|
20
21
|
try {
|
|
21
22
|
const mermaid = (await import("mermaid")).default;
|
|
22
|
-
mermaid.initialize(
|
|
23
|
+
mermaid.initialize(buildMermaidConfig());
|
|
23
24
|
const { svg } = await mermaid.render(
|
|
24
25
|
id ?? `mermaid-${Math.random().toString(36).slice(2)}`,
|
|
25
26
|
chart,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Check, X } from "lucide-react";
|
|
2
|
-
import { useId, useState } from "react";
|
|
2
|
+
import { useEffect, useId, useState } from "react";
|
|
3
3
|
import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
|
|
4
4
|
import { useProgress } from "../../lib/progress/useProgress";
|
|
5
5
|
|
|
@@ -25,6 +25,10 @@ export default function Quiz({ question, options, answer, explanation, id, multi
|
|
|
25
25
|
const correctSet = new Set(Array.isArray(answer) ? answer : [answer]);
|
|
26
26
|
const expectMulti = multi ?? Array.isArray(answer);
|
|
27
27
|
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
document.dispatchEvent(new CustomEvent("hz:step-item"));
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
28
32
|
function toggle(i: number) {
|
|
29
33
|
if (submitted) return;
|
|
30
34
|
if (expectMulti) {
|
|
@@ -47,7 +51,7 @@ export default function Quiz({ question, options, answer, explanation, id, multi
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
return (
|
|
50
|
-
<fieldset className="quiz" disabled={submitted}>
|
|
54
|
+
<fieldset className="quiz" data-quiz-id={questionId} disabled={submitted}>
|
|
51
55
|
<legend className="quiz-q">{question}</legend>
|
|
52
56
|
<div className="quiz-options">
|
|
53
57
|
{options.map((opt, i) => {
|
|
@@ -140,11 +140,33 @@ const socialImageUrl = ogImageUrl ? withBase(ogImageUrl) : undefined;
|
|
|
140
140
|
/>
|
|
141
141
|
)}
|
|
142
142
|
<script>
|
|
143
|
-
// Render any <pre class="mermaid"> blocks emitted by rehype-mermaid
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
// Render any <pre class="mermaid"> blocks emitted by rehype-mermaid,
|
|
144
|
+
// themed to match the active site palette (see lib/mermaid-theme).
|
|
145
|
+
//
|
|
146
|
+
// We capture each block's source synchronously (this module runs before
|
|
147
|
+
// the window `load` event) and render explicitly rather than calling
|
|
148
|
+
// mermaid.run(). mermaid auto-runs on `load` with its default theme, and
|
|
149
|
+
// on a fast load that race can win and mark nodes data-processed before
|
|
150
|
+
// our dynamic import resolves — leaving unthemed diagrams. Rendering each
|
|
151
|
+
// block ourselves from the captured source sidesteps that entirely.
|
|
152
|
+
const mermaidBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
|
|
153
|
+
if (mermaidBlocks.length > 0) {
|
|
154
|
+
const sources = mermaidBlocks.map((el) => el.textContent ?? "");
|
|
155
|
+
Promise.all([
|
|
156
|
+
import("mermaid"),
|
|
157
|
+
import("handzon-core/lib/mermaid-theme.ts"),
|
|
158
|
+
]).then(async ([{ default: mermaid }, { buildMermaidConfig }]) => {
|
|
159
|
+
mermaid.startOnLoad = false;
|
|
160
|
+
mermaid.initialize(buildMermaidConfig());
|
|
161
|
+
for (let i = 0; i < mermaidBlocks.length; i++) {
|
|
162
|
+
try {
|
|
163
|
+
const { svg } = await mermaid.render(`hz-mermaid-${i}`, sources[i]);
|
|
164
|
+
mermaidBlocks[i].innerHTML = svg;
|
|
165
|
+
mermaidBlocks[i].setAttribute("data-processed", "true");
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error("Mermaid render failed", err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
148
170
|
});
|
|
149
171
|
}
|
|
150
172
|
</script>
|
|
@@ -13,6 +13,7 @@ interface Props {
|
|
|
13
13
|
currentStep: StepEntry;
|
|
14
14
|
currentStepSlug: string;
|
|
15
15
|
hasCheckpoint?: boolean;
|
|
16
|
+
hasQuiz?: boolean;
|
|
16
17
|
siteName?: string;
|
|
17
18
|
tagline?: string;
|
|
18
19
|
logoUrl?: string;
|
|
@@ -33,6 +34,7 @@ const {
|
|
|
33
34
|
currentStep,
|
|
34
35
|
currentStepSlug,
|
|
35
36
|
hasCheckpoint = false,
|
|
37
|
+
hasQuiz = false,
|
|
36
38
|
siteName,
|
|
37
39
|
tagline,
|
|
38
40
|
logoUrl,
|
|
@@ -120,6 +122,7 @@ const trackBootstrap =
|
|
|
120
122
|
currentStepSlug={currentStepSlug}
|
|
121
123
|
gated={tutorial.data.gated}
|
|
122
124
|
hasCheckpoint={hasCheckpoint}
|
|
125
|
+
hasQuiz={hasQuiz}
|
|
123
126
|
nextTutorial={nextTutorial}
|
|
124
127
|
/>
|
|
125
128
|
|
|
@@ -166,10 +169,13 @@ const trackBootstrap =
|
|
|
166
169
|
// dynamic import("~/...") bypasses Vite's resolver and the browser
|
|
167
170
|
// can't load the module.
|
|
168
171
|
import { getStore } from "../lib/progress/local";
|
|
172
|
+
import { deriveStepCompletion } from "../lib/progress/stepCompletion";
|
|
173
|
+
import type { StepKey } from "../lib/progress/types";
|
|
169
174
|
const route = document.getElementById("tt-route");
|
|
170
175
|
if (route) {
|
|
171
176
|
const tutorialSlug = route.dataset.tutorialSlug!;
|
|
172
177
|
const stepSlug = route.dataset.stepSlug!;
|
|
178
|
+
const stepKey = `${tutorialSlug}/${stepSlug}` as StepKey;
|
|
173
179
|
const tutorialSteps = JSON.parse(
|
|
174
180
|
route.dataset.tutorialSteps ?? "[]",
|
|
175
181
|
) as string[];
|
|
@@ -200,6 +206,34 @@ const trackBootstrap =
|
|
|
200
206
|
};
|
|
201
207
|
});
|
|
202
208
|
|
|
209
|
+
function readCompletionItemIds(selector: string, attr: string) {
|
|
210
|
+
const activeTrack = document.documentElement.dataset.track;
|
|
211
|
+
const ids = Array.from(document.querySelectorAll<HTMLElement>(selector))
|
|
212
|
+
.map((el) => {
|
|
213
|
+
const trackPanel = el.closest<HTMLElement>("[data-track-panel]");
|
|
214
|
+
if (activeTrack && trackPanel?.dataset.trackPanel !== activeTrack) return null;
|
|
215
|
+
return el.dataset[attr];
|
|
216
|
+
})
|
|
217
|
+
.filter((id): id is string => !!id);
|
|
218
|
+
return Array.from(new Set(ids));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function recomputeStepCompletion() {
|
|
222
|
+
const completion = deriveStepCompletion(store.get(), {
|
|
223
|
+
quizIds: readCompletionItemIds("[data-quiz-id]", "quizId"),
|
|
224
|
+
checkpointIds: readCompletionItemIds("[data-checkpoint-id]", "checkpointId"),
|
|
225
|
+
});
|
|
226
|
+
if (completion === null) return;
|
|
227
|
+
store.set((s) => {
|
|
228
|
+
if (s.steps[stepKey] === completion) return s;
|
|
229
|
+
return { ...s, steps: { ...s.steps, [stepKey]: completion } };
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
recomputeStepCompletion();
|
|
234
|
+
document.addEventListener("hz:step-item", recomputeStepCompletion);
|
|
235
|
+
store.subscribe(recomputeStepCompletion);
|
|
236
|
+
|
|
203
237
|
// Watch for tutorial completion: once every step in the embedded
|
|
204
238
|
// list flips to "complete", record the "completed" event exactly
|
|
205
239
|
// once. Re-runs on store changes so finishing the last step on a
|
package/src/lib/icons.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { MermaidConfig } from "mermaid";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser-only helpers that derive a Mermaid configuration from the active
|
|
5
|
+
* theme's CSS custom properties. Mermaid bakes colors into the rendered SVG
|
|
6
|
+
* and runs color math (via khroma) on its theme variables, so we cannot hand
|
|
7
|
+
* it raw `var(--token)` references or `oklch()` strings. Instead we read the
|
|
8
|
+
* computed token values and normalize each to a hex/rgb string the renderer
|
|
9
|
+
* can manipulate, then feed Mermaid's `base` theme so diagrams inherit the
|
|
10
|
+
* site palette, fonts, and light/dark mode rather than Mermaid's stock theme.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalize any CSS color string (including `oklch()`) to a `#rrggbb` string.
|
|
15
|
+
* We rasterize one pixel and read the bytes back rather than reading
|
|
16
|
+
* `ctx.fillStyle`, because Chromium re-serializes `oklch()` as `oklch()` and
|
|
17
|
+
* Mermaid's color library (khroma) only understands hex/rgb/hsl. Reading the
|
|
18
|
+
* pixel forces a concrete sRGB value the renderer can manipulate. Returns the
|
|
19
|
+
* fallback when the value is empty or the browser cannot parse it.
|
|
20
|
+
*/
|
|
21
|
+
function resolveColor(value: string, fallback: string): string {
|
|
22
|
+
const input = value.trim();
|
|
23
|
+
if (!input) return fallback;
|
|
24
|
+
const ctx = document.createElement("canvas").getContext("2d", {
|
|
25
|
+
willReadFrequently: true,
|
|
26
|
+
});
|
|
27
|
+
if (!ctx) return fallback;
|
|
28
|
+
// Seed with a sentinel; if the input is rejected the pixel stays this value.
|
|
29
|
+
ctx.fillStyle = "#ff00ff";
|
|
30
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
31
|
+
ctx.fillStyle = input;
|
|
32
|
+
if (ctx.fillStyle === "#ff00ff" && input.toLowerCase() !== "#ff00ff") {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
36
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
37
|
+
const hex = (n: number) => n.toString(16).padStart(2, "0");
|
|
38
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Relative luminance (0–1) of a `#rrggbb` color, for dark-mode detection. */
|
|
42
|
+
function luminance(hex: string): number {
|
|
43
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex);
|
|
44
|
+
if (!m) return 0;
|
|
45
|
+
const int = parseInt(m[1], 16);
|
|
46
|
+
const channel = (c: number) => {
|
|
47
|
+
const s = c / 255;
|
|
48
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
49
|
+
};
|
|
50
|
+
const r = channel((int >> 16) & 0xff);
|
|
51
|
+
const g = channel((int >> 8) & 0xff);
|
|
52
|
+
const b = channel(int & 0xff);
|
|
53
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build a Mermaid config from the document's active theme tokens. */
|
|
57
|
+
export function buildMermaidConfig(): MermaidConfig {
|
|
58
|
+
const cs = getComputedStyle(document.documentElement);
|
|
59
|
+
const token = (name: string) => cs.getPropertyValue(name).trim();
|
|
60
|
+
const color = (name: string, fallback: string) => resolveColor(token(name), fallback);
|
|
61
|
+
|
|
62
|
+
const bg = color("--color-bg", "#0a0a0a");
|
|
63
|
+
const surface = color("--color-surface", "#16181d");
|
|
64
|
+
const surface2 = color("--color-surface-2", surface);
|
|
65
|
+
const fg = color("--color-fg", "#f5f5f5");
|
|
66
|
+
const muted = color("--color-muted", "#9ca3af");
|
|
67
|
+
const border = color("--color-border", "#3a3a3a");
|
|
68
|
+
const borderStrong = color("--color-border-strong", border);
|
|
69
|
+
const accent = color("--color-accent", "#8b5cf6");
|
|
70
|
+
const accentFg = color("--color-accent-fg", "#ffffff");
|
|
71
|
+
|
|
72
|
+
const fontFamily = token("--font-sans") || "ui-sans-serif, system-ui, sans-serif";
|
|
73
|
+
const darkMode = luminance(bg) < 0.5;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
startOnLoad: false,
|
|
77
|
+
securityLevel: "strict",
|
|
78
|
+
theme: "base",
|
|
79
|
+
fontFamily,
|
|
80
|
+
themeVariables: {
|
|
81
|
+
darkMode,
|
|
82
|
+
background: surface,
|
|
83
|
+
fontFamily,
|
|
84
|
+
// Nodes
|
|
85
|
+
primaryColor: surface2,
|
|
86
|
+
primaryTextColor: fg,
|
|
87
|
+
primaryBorderColor: accent,
|
|
88
|
+
secondaryColor: surface,
|
|
89
|
+
secondaryTextColor: fg,
|
|
90
|
+
secondaryBorderColor: border,
|
|
91
|
+
tertiaryColor: surface,
|
|
92
|
+
tertiaryTextColor: fg,
|
|
93
|
+
tertiaryBorderColor: border,
|
|
94
|
+
mainBkg: surface2,
|
|
95
|
+
nodeBorder: accent,
|
|
96
|
+
nodeTextColor: fg,
|
|
97
|
+
// Edges + general text
|
|
98
|
+
lineColor: borderStrong,
|
|
99
|
+
textColor: fg,
|
|
100
|
+
titleColor: fg,
|
|
101
|
+
edgeLabelBackground: surface,
|
|
102
|
+
// Clusters / subgraphs
|
|
103
|
+
clusterBkg: surface,
|
|
104
|
+
clusterBorder: border,
|
|
105
|
+
// Notes
|
|
106
|
+
noteBkgColor: surface2,
|
|
107
|
+
noteTextColor: fg,
|
|
108
|
+
noteBorderColor: accent,
|
|
109
|
+
// Sequence diagrams
|
|
110
|
+
actorBkg: surface2,
|
|
111
|
+
actorBorder: accent,
|
|
112
|
+
actorTextColor: fg,
|
|
113
|
+
actorLineColor: borderStrong,
|
|
114
|
+
signalColor: fg,
|
|
115
|
+
signalTextColor: fg,
|
|
116
|
+
labelBoxBkgColor: surface2,
|
|
117
|
+
labelBoxBorderColor: border,
|
|
118
|
+
labelTextColor: fg,
|
|
119
|
+
loopTextColor: fg,
|
|
120
|
+
activationBkgColor: accent,
|
|
121
|
+
activationBorderColor: accent,
|
|
122
|
+
// Accent emphasis
|
|
123
|
+
altBackground: surface,
|
|
124
|
+
errorBkgColor: surface2,
|
|
125
|
+
errorTextColor: muted,
|
|
126
|
+
// Keep the accent legible where Mermaid fills with it.
|
|
127
|
+
primaryColorText: accentFg,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ProgressState } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface StepCompletionItems {
|
|
4
|
+
quizIds: string[];
|
|
5
|
+
checkpointIds: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type DerivedStepCompletion = "complete" | "incomplete" | null;
|
|
9
|
+
|
|
10
|
+
export function deriveStepCompletion(
|
|
11
|
+
state: ProgressState,
|
|
12
|
+
{ quizIds, checkpointIds }: StepCompletionItems,
|
|
13
|
+
): DerivedStepCompletion {
|
|
14
|
+
if (quizIds.length === 0 && checkpointIds.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
const allQuizzesCorrect = quizIds.every((id) => state.quizzes[id]?.correct === true);
|
|
17
|
+
const allCheckpointsDone = checkpointIds.every((id) => !!state.checkpoints[id]);
|
|
18
|
+
|
|
19
|
+
return allQuizzesCorrect && allCheckpointsDone ? "complete" : "incomplete";
|
|
20
|
+
}
|
|
@@ -35,6 +35,11 @@ export type ProgressState = {
|
|
|
35
35
|
steps: Record<StepKey, "incomplete" | "complete">;
|
|
36
36
|
quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
|
|
37
37
|
checkpoints: Record<string, { ts: number }>;
|
|
38
|
+
/**
|
|
39
|
+
* Per-learner favorite tutorials. Keyed by tutorial slug; the value is
|
|
40
|
+
* the timestamp when the learner last favorited it.
|
|
41
|
+
*/
|
|
42
|
+
favorites: Record<string, number>;
|
|
38
43
|
/**
|
|
39
44
|
* Latest verification verdict per checkpoint id. `pass: true`
|
|
40
45
|
* entries hang around as evidence; the Family D UI only renders
|
|
@@ -73,6 +78,7 @@ export const emptyState = (): ProgressState => ({
|
|
|
73
78
|
steps: {},
|
|
74
79
|
quizzes: {},
|
|
75
80
|
checkpoints: {},
|
|
81
|
+
favorites: {},
|
|
76
82
|
verificationFeedback: {},
|
|
77
83
|
prefs: {},
|
|
78
84
|
lastVisited: {},
|
|
@@ -16,7 +16,9 @@ interface ProgressApi {
|
|
|
16
16
|
setLastVisited: (tutorial: string, step: string) => void;
|
|
17
17
|
markTutorialStarted: (tutorial: string) => void;
|
|
18
18
|
markTutorialCompleted: (tutorial: string) => void;
|
|
19
|
+
toggleFavorite: (tutorial: string) => void;
|
|
19
20
|
isStepComplete: (tutorial: string, step: string) => boolean;
|
|
21
|
+
isFavorite: (tutorial: string) => boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -100,6 +102,16 @@ export function useProgress(): ProgressApi {
|
|
|
100
102
|
},
|
|
101
103
|
};
|
|
102
104
|
}),
|
|
105
|
+
toggleFavorite: (tutorial: string) =>
|
|
106
|
+
store.set((s) => {
|
|
107
|
+
const nextFavorites = { ...s.favorites };
|
|
108
|
+
if (nextFavorites[tutorial]) {
|
|
109
|
+
delete nextFavorites[tutorial];
|
|
110
|
+
} else {
|
|
111
|
+
nextFavorites[tutorial] = Date.now();
|
|
112
|
+
}
|
|
113
|
+
return { ...s, favorites: nextFavorites };
|
|
114
|
+
}),
|
|
103
115
|
};
|
|
104
116
|
}, [store]);
|
|
105
117
|
|
|
@@ -108,6 +120,7 @@ export function useProgress(): ProgressApi {
|
|
|
108
120
|
...actions,
|
|
109
121
|
isStepComplete: (tutorial, step) =>
|
|
110
122
|
state.steps[`${tutorial}/${step}` as StepKey] === "complete",
|
|
123
|
+
isFavorite: (tutorial) => !!state.favorites[tutorial],
|
|
111
124
|
};
|
|
112
125
|
}
|
|
113
126
|
|
package/src/pages/Home.astro
CHANGED
|
@@ -168,6 +168,64 @@ for (const t of tutorials) {
|
|
|
168
168
|
getStore().subscribe(refresh);
|
|
169
169
|
</script>
|
|
170
170
|
|
|
171
|
+
<script>
|
|
172
|
+
// Hydrate per-card favorites from the same local progress store used
|
|
173
|
+
// by steps and preferences. Cards expose `data-favorited` so the
|
|
174
|
+
// FilterBar can compose favorites with search, facets, and pagination.
|
|
175
|
+
import { getStore } from "../lib/progress/local.ts";
|
|
176
|
+
const store = getStore();
|
|
177
|
+
let lastFavoritesSignature: string | null = null;
|
|
178
|
+
|
|
179
|
+
function favoriteSignature(favorites: Record<string, number>): string {
|
|
180
|
+
return Object.keys(favorites).sort().join("\0");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function paintFavorites() {
|
|
184
|
+
const favorites = store.get().favorites ?? {};
|
|
185
|
+
const favoriteSlugs = new Set(Object.keys(favorites));
|
|
186
|
+
const signature = favoriteSignature(favorites);
|
|
187
|
+
document.querySelectorAll<HTMLButtonElement>("[data-fav-slug]").forEach((button) => {
|
|
188
|
+
const slug = button.dataset.favSlug!;
|
|
189
|
+
const card = button.closest<HTMLElement>("[data-card-slug]");
|
|
190
|
+
const title = card?.querySelector("h3")?.textContent?.trim() ?? slug;
|
|
191
|
+
const isFavorite = favoriteSlugs.has(slug);
|
|
192
|
+
button.setAttribute("aria-pressed", String(isFavorite));
|
|
193
|
+
button.setAttribute(
|
|
194
|
+
"aria-label",
|
|
195
|
+
isFavorite ? `Remove ${title} from favorites` : `Add ${title} to favorites`,
|
|
196
|
+
);
|
|
197
|
+
if (isFavorite) {
|
|
198
|
+
card?.setAttribute("data-favorited", "");
|
|
199
|
+
} else {
|
|
200
|
+
card?.removeAttribute("data-favorited");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
if (signature !== lastFavoritesSignature) {
|
|
204
|
+
lastFavoritesSignature = signature;
|
|
205
|
+
window.dispatchEvent(new CustomEvent("hz:favorites-changed"));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
document.querySelectorAll<HTMLButtonElement>("[data-fav-slug]").forEach((button) => {
|
|
210
|
+
button.addEventListener("click", () => {
|
|
211
|
+
const slug = button.dataset.favSlug;
|
|
212
|
+
if (!slug) return;
|
|
213
|
+
store.set((state) => {
|
|
214
|
+
const favorites = { ...state.favorites };
|
|
215
|
+
if (favorites[slug]) {
|
|
216
|
+
delete favorites[slug];
|
|
217
|
+
} else {
|
|
218
|
+
favorites[slug] = Date.now();
|
|
219
|
+
}
|
|
220
|
+
return { ...state, favorites };
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
paintFavorites();
|
|
226
|
+
store.subscribe(paintFavorites);
|
|
227
|
+
</script>
|
|
228
|
+
|
|
171
229
|
<script>
|
|
172
230
|
// Hydrate cross-learner popularity numbers and the `data-popularity`
|
|
173
231
|
// attribute used by the FilterBar's "Popular" sort. Single fetch on
|
|
@@ -217,22 +275,36 @@ for (const t of tutorials) {
|
|
|
217
275
|
</script>
|
|
218
276
|
|
|
219
277
|
<script>
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
278
|
+
// Home-page ordering: tutorials this learner already started come
|
|
279
|
+
// first. Within that group, either keep the curated server order or
|
|
280
|
+
// apply the selected popularity sort.
|
|
281
|
+
import { getStore } from "../lib/progress/local.ts";
|
|
282
|
+
const store = getStore();
|
|
283
|
+
|
|
284
|
+
function hasStarted(card: HTMLElement): boolean {
|
|
285
|
+
const slug = card.dataset.cardSlug;
|
|
286
|
+
if (!slug) return false;
|
|
287
|
+
return !!store.get().tutorials[slug]?.started;
|
|
288
|
+
}
|
|
289
|
+
|
|
224
290
|
function applySort(mode: string) {
|
|
225
|
-
const cards = document.querySelectorAll<HTMLElement>("[data-card-slug]");
|
|
291
|
+
const cards = Array.from(document.querySelectorAll<HTMLElement>("[data-card-slug]"));
|
|
292
|
+
const originalOrder = new Map(cards.map((card, idx) => [card, idx]));
|
|
226
293
|
if (mode === "popular") {
|
|
227
|
-
const sorted =
|
|
228
|
-
|
|
294
|
+
const sorted = cards.sort((a, b) => {
|
|
295
|
+
const startedDelta = Number(hasStarted(b)) - Number(hasStarted(a));
|
|
296
|
+
if (startedDelta !== 0) return startedDelta;
|
|
297
|
+
const popularityDelta =
|
|
298
|
+
Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
|
|
299
|
+
if (popularityDelta !== 0) return popularityDelta;
|
|
300
|
+
return (originalOrder.get(a) ?? 0) - (originalOrder.get(b) ?? 0);
|
|
229
301
|
});
|
|
230
302
|
sorted.forEach((card, idx) => {
|
|
231
303
|
card.style.order = String(idx);
|
|
232
304
|
});
|
|
233
305
|
} else {
|
|
234
|
-
cards.forEach((card) => {
|
|
235
|
-
card.style.
|
|
306
|
+
cards.forEach((card, idx) => {
|
|
307
|
+
card.style.order = String(hasStarted(card) ? idx : idx + cards.length);
|
|
236
308
|
});
|
|
237
309
|
}
|
|
238
310
|
}
|
|
@@ -245,6 +317,7 @@ for (const t of tutorials) {
|
|
|
245
317
|
applySort((e as CustomEvent<{ sort: string }>).detail.sort);
|
|
246
318
|
});
|
|
247
319
|
window.addEventListener("hz:stats-loaded", syncFromUrl);
|
|
320
|
+
store.subscribe(syncFromUrl);
|
|
248
321
|
</script>
|
|
249
322
|
</BaseLayout>
|
|
250
323
|
|
|
@@ -52,6 +52,7 @@ const { Content } = await render(currentStep);
|
|
|
52
52
|
|
|
53
53
|
const components = mdxComponents();
|
|
54
54
|
const hasCheckpoint = currentStep.body?.includes("<Checkpoint") ?? false;
|
|
55
|
+
const hasQuiz = currentStep.body?.includes("<Quiz") ?? false;
|
|
55
56
|
const nextTutorial = tutorial.data.nextTutorial
|
|
56
57
|
? await getTutorialBySlug(tutorial.data.nextTutorial)
|
|
57
58
|
: undefined;
|
|
@@ -76,6 +77,7 @@ const initialContext = buildContext({
|
|
|
76
77
|
currentStep={currentStep}
|
|
77
78
|
currentStepSlug={stepSlug}
|
|
78
79
|
hasCheckpoint={hasCheckpoint}
|
|
80
|
+
hasQuiz={hasQuiz}
|
|
79
81
|
siteName={siteName}
|
|
80
82
|
tagline={tagline}
|
|
81
83
|
logoUrl={logoUrl}
|
|
@@ -65,6 +65,35 @@
|
|
|
65
65
|
flex-shrink: 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
.favorites-filter {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 0.45rem;
|
|
72
|
+
min-height: 2.5rem;
|
|
73
|
+
padding: 0.4rem 0.65rem;
|
|
74
|
+
background: transparent;
|
|
75
|
+
border: 1px solid var(--color-border);
|
|
76
|
+
color: var(--color-fg);
|
|
77
|
+
font-family: var(--font-mono);
|
|
78
|
+
font-size: 0.78em;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
|
81
|
+
}
|
|
82
|
+
.favorites-filter:hover,
|
|
83
|
+
.favorites-filter:focus-visible {
|
|
84
|
+
border-color: var(--color-accent);
|
|
85
|
+
color: var(--color-accent);
|
|
86
|
+
outline: none;
|
|
87
|
+
}
|
|
88
|
+
.favorites-filter[aria-pressed="true"] {
|
|
89
|
+
border-color: var(--color-accent);
|
|
90
|
+
color: var(--color-accent);
|
|
91
|
+
background: color-mix(in oklab, var(--color-accent) 8%, transparent);
|
|
92
|
+
}
|
|
93
|
+
.favorites-filter[aria-pressed="true"] svg {
|
|
94
|
+
fill: currentColor;
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
/* On narrow viewports the toolbar wraps; sort drops to its own line.
|
|
69
98
|
* Keep the divider visible only when the row hasn't wrapped — when it
|
|
70
99
|
* has, the layout already reads as distinct rows. Cheap heuristic:
|
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
/* Mermaid
|
|
2
|
-
|
|
1
|
+
/* Mermaid — diagram colors come from JS (lib/mermaid-theme) so the SVG
|
|
2
|
+
* inherits the active palette; this only frames the rendered diagram so it
|
|
3
|
+
* reads as part of the page rather than a floating image. */
|
|
4
|
+
.mermaid-wrap,
|
|
5
|
+
pre.mermaid {
|
|
3
6
|
margin: 1.25rem 0;
|
|
4
7
|
padding: 1rem;
|
|
5
8
|
background: var(--color-surface);
|
|
9
|
+
border: var(--border-default, 1px) solid var(--color-border);
|
|
10
|
+
border-radius: var(--radius-md, 0);
|
|
6
11
|
display: grid;
|
|
7
12
|
place-items: center;
|
|
8
|
-
border-radius: 0;
|
|
9
13
|
min-height: var(--mermaid-min-height, 14rem);
|
|
14
|
+
overflow-x: auto;
|
|
10
15
|
}
|
|
11
|
-
|
|
12
|
-
pre.mermaid
|
|
13
|
-
|
|
16
|
+
/* Before the client script runs, the raw fenced source sits inside
|
|
17
|
+
* pre.mermaid; keep it from flashing as monospace text. */
|
|
18
|
+
pre.mermaid:not([data-processed]) {
|
|
19
|
+
color: transparent;
|
|
20
|
+
}
|
|
21
|
+
.mermaid-wrap :global(svg),
|
|
22
|
+
pre.mermaid svg {
|
|
23
|
+
max-width: 100%;
|
|
24
|
+
height: auto;
|
|
25
|
+
}
|
|
26
|
+
.mermaid-wrap :global(svg) text,
|
|
27
|
+
pre.mermaid svg text {
|
|
28
|
+
font-family: var(--font-sans);
|
|
14
29
|
}
|
|
15
30
|
.mermaid-error {
|
|
16
31
|
padding: 0.75rem;
|