handzon-core 0.16.1 → 0.17.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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"
|
|
@@ -16,7 +16,7 @@ interface Props {
|
|
|
16
16
|
const { tutorial, steps, currentStepSlug } = Astro.props;
|
|
17
17
|
const slug = tutorial.id;
|
|
18
18
|
---
|
|
19
|
-
<aside class="sidebar" aria-label="Tutorial steps">
|
|
19
|
+
<aside class="sidebar" aria-label="Tutorial steps" data-gated={tutorial.data.gated ? "true" : "false"}>
|
|
20
20
|
{/* Top block: back link, title, progress. Stays fixed while the
|
|
21
21
|
* step list below scrolls when there are many steps. */}
|
|
22
22
|
<div class="sb-top">
|
|
@@ -77,10 +77,11 @@ const slug = tutorial.id;
|
|
|
77
77
|
const isCurrent = stepSlug === currentStepSlug;
|
|
78
78
|
return (
|
|
79
79
|
<li class={isCurrent ? "is-current" : ""} data-step-slug={stepSlug}>
|
|
80
|
-
<a href={withBase(`/${slug}/${stepSlug}`)}>
|
|
80
|
+
<a href={withBase(`/${slug}/${stepSlug}`)} data-step-link="true">
|
|
81
81
|
<span class="sb-check" data-step-key={`${slug}/${stepSlug}`}></span>
|
|
82
82
|
<span class="sb-name">{step.data.title}</span>
|
|
83
83
|
{step.data.duration && <span class="sb-dur">{step.data.duration}</span>}
|
|
84
|
+
<span class="sb-lock" aria-hidden="true">Locked</span>
|
|
84
85
|
</a>
|
|
85
86
|
</li>
|
|
86
87
|
);
|
|
@@ -91,13 +92,17 @@ const slug = tutorial.id;
|
|
|
91
92
|
{/* Pre-paint: mirror the persisted progress state into the SSR sidebar
|
|
92
93
|
before hydrated modules run, avoiding a step-change flash from
|
|
93
94
|
0 / unchecked to the learner's real progress. */}
|
|
94
|
-
<script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length }}>
|
|
95
|
+
<script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length, tutorialSteps: steps.map((step) => parseStepId(step.id).stepSlug), gated: tutorial.data.gated }}>
|
|
95
96
|
(function () {
|
|
96
97
|
try {
|
|
97
98
|
var raw = window.localStorage.getItem(storageKey);
|
|
98
99
|
var state = raw ? JSON.parse(raw) : null;
|
|
99
100
|
var steps = (state && state.steps) || {};
|
|
100
101
|
var completed = 0;
|
|
102
|
+
var firstIncomplete = tutorialSteps.find(function (slug) {
|
|
103
|
+
return steps[tutorialSlug + "/" + slug] !== "complete";
|
|
104
|
+
});
|
|
105
|
+
var lockStart = gated && firstIncomplete ? tutorialSteps.indexOf(firstIncomplete) + 1 : -1;
|
|
101
106
|
|
|
102
107
|
document.querySelectorAll("[data-step-key]").forEach(function (el) {
|
|
103
108
|
var key = el.getAttribute("data-step-key");
|
|
@@ -105,6 +110,12 @@ const slug = tutorial.id;
|
|
|
105
110
|
el.setAttribute("data-done", done ? "true" : "false");
|
|
106
111
|
if (done && key.indexOf(tutorialSlug + "/") === 0) completed += 1;
|
|
107
112
|
});
|
|
113
|
+
if (lockStart > 0) {
|
|
114
|
+
tutorialSteps.slice(lockStart).forEach(function (slug) {
|
|
115
|
+
var item = document.querySelector('[data-step-slug="' + slug + '"]');
|
|
116
|
+
if (item) item.setAttribute("data-locked", "true");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
108
119
|
|
|
109
120
|
var progress = document.querySelector(".sidebar .progress");
|
|
110
121
|
if (!progress) return;
|
|
@@ -125,14 +136,50 @@ const slug = tutorial.id;
|
|
|
125
136
|
<script>
|
|
126
137
|
// Hydrate per-step check marks from localStorage without an island.
|
|
127
138
|
import { getStore } from "../lib/progress/local";
|
|
139
|
+
import { lockedStepSlugs } from "../lib/progress/gating";
|
|
140
|
+
|
|
141
|
+
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
|
142
|
+
const tutorialSlug = sidebar?.querySelector<HTMLElement>("[data-step-key]")?.dataset.stepKey?.split("/")[0];
|
|
143
|
+
const gated = sidebar?.dataset.gated === "true";
|
|
144
|
+
const tutorialSteps = Array.from(document.querySelectorAll<HTMLElement>("[data-step-slug]")).map(
|
|
145
|
+
(el) => el.dataset.stepSlug!,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
sidebar?.addEventListener("click", (e) => {
|
|
149
|
+
const link = (e.target as Element).closest<HTMLAnchorElement>(
|
|
150
|
+
"[data-step-link][aria-disabled='true']",
|
|
151
|
+
);
|
|
152
|
+
if (link) e.preventDefault();
|
|
153
|
+
});
|
|
154
|
+
|
|
128
155
|
function refresh() {
|
|
129
156
|
const store = getStore();
|
|
130
157
|
const state = store.get();
|
|
158
|
+
const locked =
|
|
159
|
+
gated && tutorialSlug
|
|
160
|
+
? new Set(lockedStepSlugs({ tutorialSlug, stepSlugs: tutorialSteps, steps: state.steps }))
|
|
161
|
+
: new Set<string>();
|
|
131
162
|
document.querySelectorAll<HTMLElement>("[data-step-key]").forEach((el) => {
|
|
132
163
|
const key = el.dataset.stepKey as `${string}/${string}`;
|
|
133
164
|
const done = state.steps[key] === "complete";
|
|
134
165
|
el.dataset.done = done ? "true" : "false";
|
|
135
166
|
});
|
|
167
|
+
document.querySelectorAll<HTMLElement>("[data-step-slug]").forEach((el) => {
|
|
168
|
+
const stepSlug = el.dataset.stepSlug!;
|
|
169
|
+
const link = el.querySelector<HTMLAnchorElement>("[data-step-link]");
|
|
170
|
+
const isLocked = locked.has(stepSlug);
|
|
171
|
+
el.dataset.locked = isLocked ? "true" : "false";
|
|
172
|
+
if (!link) return;
|
|
173
|
+
if (isLocked) {
|
|
174
|
+
link.setAttribute("aria-disabled", "true");
|
|
175
|
+
link.setAttribute("tabindex", "-1");
|
|
176
|
+
link.setAttribute("title", "Complete previous steps to unlock this step.");
|
|
177
|
+
} else {
|
|
178
|
+
link.removeAttribute("aria-disabled");
|
|
179
|
+
link.removeAttribute("tabindex");
|
|
180
|
+
link.removeAttribute("title");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
136
183
|
}
|
|
137
184
|
refresh();
|
|
138
185
|
getStore().subscribe(refresh);
|
|
@@ -227,6 +274,14 @@ const slug = tutorial.id;
|
|
|
227
274
|
background: var(--color-surface);
|
|
228
275
|
color: var(--color-fg);
|
|
229
276
|
}
|
|
277
|
+
.sb-steps li[data-locked="true"] a {
|
|
278
|
+
cursor: not-allowed;
|
|
279
|
+
opacity: 0.48;
|
|
280
|
+
}
|
|
281
|
+
.sb-steps li[data-locked="true"] a:hover {
|
|
282
|
+
background: transparent;
|
|
283
|
+
box-shadow: none;
|
|
284
|
+
}
|
|
230
285
|
.sb-steps li.is-current a {
|
|
231
286
|
background: color-mix(in oklab, var(--color-accent) 40%, var(--color-bg));
|
|
232
287
|
color: var(--color-fg);
|
|
@@ -253,4 +308,18 @@ const slug = tutorial.id;
|
|
|
253
308
|
font-weight: 400;
|
|
254
309
|
letter-spacing: 0.02em;
|
|
255
310
|
}
|
|
311
|
+
.sb-lock {
|
|
312
|
+
display: none;
|
|
313
|
+
position: absolute;
|
|
314
|
+
right: 1rem;
|
|
315
|
+
bottom: 0.45rem;
|
|
316
|
+
color: var(--color-muted);
|
|
317
|
+
font-family: var(--font-mono);
|
|
318
|
+
font-size: 0.68em;
|
|
319
|
+
letter-spacing: 0.04em;
|
|
320
|
+
text-transform: uppercase;
|
|
321
|
+
}
|
|
322
|
+
.sb-steps li[data-locked="true"] .sb-lock {
|
|
323
|
+
display: inline;
|
|
324
|
+
}
|
|
256
325
|
</style>
|
|
@@ -140,6 +140,7 @@ const trackBootstrap =
|
|
|
140
140
|
id="tt-route"
|
|
141
141
|
data-tutorial-slug={tutorial.id}
|
|
142
142
|
data-step-slug={currentStepSlug}
|
|
143
|
+
data-tutorial-gated={tutorial.data.gated ? "true" : "false"}
|
|
143
144
|
data-tutorial-title={tutorial.data.title}
|
|
144
145
|
data-tutorial-steps={JSON.stringify(stepSlugs)}
|
|
145
146
|
hidden
|
|
@@ -168,6 +169,8 @@ const trackBootstrap =
|
|
|
168
169
|
// import so Vite resolves the path alias correctly; define:vars +
|
|
169
170
|
// dynamic import("~/...") bypasses Vite's resolver and the browser
|
|
170
171
|
// can't load the module.
|
|
172
|
+
import { withBase } from "../lib/base";
|
|
173
|
+
import { firstIncompletePrerequisite } from "../lib/progress/gating";
|
|
171
174
|
import { getStore } from "../lib/progress/local";
|
|
172
175
|
import { deriveStepCompletion } from "../lib/progress/stepCompletion";
|
|
173
176
|
import type { StepKey } from "../lib/progress/types";
|
|
@@ -180,86 +183,99 @@ const trackBootstrap =
|
|
|
180
183
|
route.dataset.tutorialSteps ?? "[]",
|
|
181
184
|
) as string[];
|
|
182
185
|
const store = getStore();
|
|
186
|
+
const redirectStep =
|
|
187
|
+
route.dataset.tutorialGated === "true"
|
|
188
|
+
? firstIncompletePrerequisite({
|
|
189
|
+
tutorialSlug,
|
|
190
|
+
stepSlugs: tutorialSteps,
|
|
191
|
+
stepSlug,
|
|
192
|
+
steps: store.get().steps,
|
|
193
|
+
})
|
|
194
|
+
: undefined;
|
|
183
195
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
...s
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
196
|
+
if (redirectStep && redirectStep !== stepSlug) {
|
|
197
|
+
window.location.replace(withBase(`/${tutorialSlug}/${redirectStep}`));
|
|
198
|
+
} else {
|
|
199
|
+
// Mark last-visited + tutorial "started" on every mount. The
|
|
200
|
+
// started branch is idempotent: it only writes the timestamp the
|
|
201
|
+
// first time per learner, so /api/progress sees one POST per
|
|
202
|
+
// tutorial — not one per page view.
|
|
203
|
+
store.set((s) => {
|
|
204
|
+
const nextLastVisited = {
|
|
205
|
+
...s.lastVisited,
|
|
206
|
+
[tutorialSlug]: { step: stepSlug, ts: Date.now() },
|
|
207
|
+
};
|
|
208
|
+
if (s.tutorials[tutorialSlug]?.started) {
|
|
209
|
+
return { ...s, lastVisited: nextLastVisited };
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
...s,
|
|
213
|
+
lastVisited: nextLastVisited,
|
|
214
|
+
tutorials: {
|
|
215
|
+
...s.tutorials,
|
|
216
|
+
[tutorialSlug]: {
|
|
217
|
+
...s.tutorials[tutorialSlug],
|
|
218
|
+
started: Date.now(),
|
|
219
|
+
},
|
|
204
220
|
},
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
});
|
|
221
|
+
};
|
|
222
|
+
});
|
|
208
223
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
function readCompletionItemIds(selector: string, attr: string) {
|
|
225
|
+
const activeTrack = document.documentElement.dataset.track;
|
|
226
|
+
const ids = Array.from(document.querySelectorAll<HTMLElement>(selector))
|
|
227
|
+
.map((el) => {
|
|
228
|
+
const trackPanel = el.closest<HTMLElement>("[data-track-panel]");
|
|
229
|
+
if (activeTrack && trackPanel?.dataset.trackPanel !== activeTrack) return null;
|
|
230
|
+
return el.dataset[attr];
|
|
231
|
+
})
|
|
232
|
+
.filter((id): id is string => !!id);
|
|
233
|
+
return Array.from(new Set(ids));
|
|
234
|
+
}
|
|
220
235
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
236
|
+
function recomputeStepCompletion() {
|
|
237
|
+
const completion = deriveStepCompletion(store.get(), {
|
|
238
|
+
quizIds: readCompletionItemIds("[data-quiz-id]", "quizId"),
|
|
239
|
+
checkpointIds: readCompletionItemIds("[data-checkpoint-id]", "checkpointId"),
|
|
240
|
+
});
|
|
241
|
+
if (completion === null) return;
|
|
242
|
+
store.set((s) => {
|
|
243
|
+
if (s.steps[stepKey] === completion) return s;
|
|
244
|
+
return { ...s, steps: { ...s.steps, [stepKey]: completion } };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
232
247
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
248
|
+
recomputeStepCompletion();
|
|
249
|
+
document.addEventListener("hz:step-item", recomputeStepCompletion);
|
|
250
|
+
store.subscribe(recomputeStepCompletion);
|
|
236
251
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
252
|
+
// Watch for tutorial completion: once every step in the embedded
|
|
253
|
+
// list flips to "complete", record the "completed" event exactly
|
|
254
|
+
// once. Re-runs on store changes so finishing the last step on a
|
|
255
|
+
// mid-tutorial page (rare) still triggers it.
|
|
256
|
+
function checkCompletion() {
|
|
257
|
+
const s = store.get();
|
|
258
|
+
if (s.tutorials[tutorialSlug]?.completed) return;
|
|
259
|
+
if (tutorialSteps.length === 0) return;
|
|
260
|
+
const allDone = tutorialSteps.every(
|
|
261
|
+
(slug) => s.steps[`${tutorialSlug}/${slug}` as `${string}/${string}`] === "complete",
|
|
262
|
+
);
|
|
263
|
+
if (allDone) {
|
|
264
|
+
store.set((cur) => ({
|
|
265
|
+
...cur,
|
|
266
|
+
tutorials: {
|
|
267
|
+
...cur.tutorials,
|
|
268
|
+
[tutorialSlug]: {
|
|
269
|
+
...cur.tutorials[tutorialSlug],
|
|
270
|
+
completed: Date.now(),
|
|
271
|
+
},
|
|
256
272
|
},
|
|
257
|
-
}
|
|
258
|
-
}
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
259
275
|
}
|
|
276
|
+
checkCompletion();
|
|
277
|
+
store.subscribe(checkCompletion);
|
|
260
278
|
}
|
|
261
|
-
checkCompletion();
|
|
262
|
-
store.subscribe(checkCompletion);
|
|
263
279
|
}
|
|
264
280
|
</script>
|
|
265
281
|
</BaseLayout>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProgressState, StepKey } from "./types";
|
|
2
|
+
|
|
3
|
+
type StepStatusMap = ProgressState["steps"];
|
|
4
|
+
|
|
5
|
+
interface GatingInput {
|
|
6
|
+
tutorialSlug: string;
|
|
7
|
+
stepSlugs: string[];
|
|
8
|
+
steps: StepStatusMap;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StepGateInput extends GatingInput {
|
|
12
|
+
stepSlug: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stepKey = (tutorialSlug: string, stepSlug: string): StepKey => `${tutorialSlug}/${stepSlug}`;
|
|
16
|
+
|
|
17
|
+
export function firstIncompleteStep({ tutorialSlug, stepSlugs, steps }: GatingInput) {
|
|
18
|
+
return stepSlugs.find((slug) => steps[stepKey(tutorialSlug, slug)] !== "complete");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function firstIncompletePrerequisite({
|
|
22
|
+
tutorialSlug,
|
|
23
|
+
stepSlugs,
|
|
24
|
+
stepSlug,
|
|
25
|
+
steps,
|
|
26
|
+
}: StepGateInput) {
|
|
27
|
+
const targetIndex = stepSlugs.indexOf(stepSlug);
|
|
28
|
+
if (targetIndex <= 0) return undefined;
|
|
29
|
+
return stepSlugs
|
|
30
|
+
.slice(0, targetIndex)
|
|
31
|
+
.find((slug) => steps[stepKey(tutorialSlug, slug)] !== "complete");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function canVisitGatedStep(input: StepGateInput) {
|
|
35
|
+
if (!input.stepSlugs.includes(input.stepSlug)) return true;
|
|
36
|
+
return !firstIncompletePrerequisite(input);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function lockedStepSlugs({ tutorialSlug, stepSlugs, steps }: GatingInput) {
|
|
40
|
+
const firstIncomplete = firstIncompleteStep({ tutorialSlug, stepSlugs, steps });
|
|
41
|
+
if (!firstIncomplete) return [];
|
|
42
|
+
const lockStart = stepSlugs.indexOf(firstIncomplete) + 1;
|
|
43
|
+
return stepSlugs.slice(lockStart);
|
|
44
|
+
}
|