handzon-core 0.14.1 → 0.15.1
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/Sidebar.astro +35 -0
- package/src/components/StepHeroMedia.astro +16 -0
- package/src/components/StepNav.astro +95 -6
- package/src/components/TutorialCompletion.tsx +69 -0
- package/src/components/auth/UserMenu.astro +117 -13
- package/src/components/auth/UserMenu.tsx +98 -13
- package/src/components/mdx/Embed.astro +55 -9
- package/src/index.ts +6 -0
- package/src/layouts/TutorialLayout.astro +4 -0
- package/src/lib/heroMedia.ts +10 -0
- package/src/lib/tutorialSummary.ts +32 -0
- package/src/pages/TutorialStep.astro +10 -1
- package/styles/components/chat.css +13 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
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"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { withBase } from "../lib/base";
|
|
3
3
|
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
4
4
|
import { parseStepId } from "../lib/content";
|
|
5
|
+
import { STORAGE_KEY } from "../lib/progress/types";
|
|
5
6
|
import { fallbackTextForTrack, iconForTrack } from "../lib/track-icons";
|
|
6
7
|
import Progress from "./Progress.tsx";
|
|
7
8
|
import TrackSelector from "./TrackSelector.tsx";
|
|
@@ -87,6 +88,40 @@ const slug = tutorial.id;
|
|
|
87
88
|
</ol>
|
|
88
89
|
</aside>
|
|
89
90
|
|
|
91
|
+
{/* Pre-paint: mirror the persisted progress state into the SSR sidebar
|
|
92
|
+
before hydrated modules run, avoiding a step-change flash from
|
|
93
|
+
0 / unchecked to the learner's real progress. */}
|
|
94
|
+
<script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length }}>
|
|
95
|
+
(function () {
|
|
96
|
+
try {
|
|
97
|
+
var raw = window.localStorage.getItem(storageKey);
|
|
98
|
+
var state = raw ? JSON.parse(raw) : null;
|
|
99
|
+
var steps = (state && state.steps) || {};
|
|
100
|
+
var completed = 0;
|
|
101
|
+
|
|
102
|
+
document.querySelectorAll("[data-step-key]").forEach(function (el) {
|
|
103
|
+
var key = el.getAttribute("data-step-key");
|
|
104
|
+
var done = key && steps[key] === "complete";
|
|
105
|
+
el.setAttribute("data-done", done ? "true" : "false");
|
|
106
|
+
if (done && key.indexOf(tutorialSlug + "/") === 0) completed += 1;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
var progress = document.querySelector(".sidebar .progress");
|
|
110
|
+
if (!progress) return;
|
|
111
|
+
var bounded = Math.min(completed, totalSteps);
|
|
112
|
+
var pct = totalSteps > 0 ? Math.round((bounded / totalSteps) * 100) : 0;
|
|
113
|
+
progress.setAttribute("aria-label", bounded + " of " + totalSteps + " steps complete");
|
|
114
|
+
progress.setAttribute("aria-valuenow", String(bounded));
|
|
115
|
+
var fill = progress.querySelector(".progress-fill");
|
|
116
|
+
if (fill) fill.style.width = pct + "%";
|
|
117
|
+
var label = progress.querySelector(".progress-label");
|
|
118
|
+
if (label) label.textContent = bounded + " / " + totalSteps + " steps";
|
|
119
|
+
} catch (e) {
|
|
120
|
+
/* ignore — hydrated progress will correct the sidebar */
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
</script>
|
|
124
|
+
|
|
90
125
|
<script>
|
|
91
126
|
// Hydrate per-step check marks from localStorage without an island.
|
|
92
127
|
import { getStore } from "../lib/progress/local";
|
|
@@ -18,6 +18,14 @@ type StepHeroMedia =
|
|
|
18
18
|
aspect?: string;
|
|
19
19
|
type?: "iframe" | "video";
|
|
20
20
|
caption?: string;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
kind: "slides";
|
|
24
|
+
src: string;
|
|
25
|
+
title: string;
|
|
26
|
+
aspect?: string;
|
|
27
|
+
slide?: string | number;
|
|
28
|
+
caption?: string;
|
|
21
29
|
};
|
|
22
30
|
|
|
23
31
|
interface Props {
|
|
@@ -35,6 +43,14 @@ const { media } = Astro.props;
|
|
|
35
43
|
aspect={media.aspect}
|
|
36
44
|
type={media.type}
|
|
37
45
|
/>
|
|
46
|
+
) : media.kind === "slides" ? (
|
|
47
|
+
<Embed
|
|
48
|
+
src={media.src}
|
|
49
|
+
title={media.title}
|
|
50
|
+
aspect={media.aspect}
|
|
51
|
+
type="slides"
|
|
52
|
+
slide={media.slide}
|
|
53
|
+
/>
|
|
38
54
|
) : typeof media.src !== "string" ? (
|
|
39
55
|
<Image
|
|
40
56
|
src={media.src}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
+
import TutorialCompletion from "./TutorialCompletion.tsx";
|
|
2
3
|
import { withBase } from "../lib/base";
|
|
3
4
|
import type { StepEntry } from "../lib/content";
|
|
4
5
|
import { parseStepId } from "../lib/content";
|
|
6
|
+
import type { TutorialSummary } from "../lib/tutorialSummary";
|
|
5
7
|
|
|
6
8
|
interface Props {
|
|
7
9
|
tutorialSlug: string;
|
|
@@ -9,16 +11,17 @@ interface Props {
|
|
|
9
11
|
currentStepSlug: string;
|
|
10
12
|
gated: boolean;
|
|
11
13
|
hasCheckpoint: boolean;
|
|
14
|
+
nextTutorial?: TutorialSummary;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint } = Astro.props;
|
|
17
|
+
const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint, nextTutorial } = Astro.props;
|
|
15
18
|
const idx = steps.findIndex((s) => parseStepId(s.id).stepSlug === currentStepSlug);
|
|
16
19
|
const prev = idx > 0 ? steps[idx - 1] : null;
|
|
17
20
|
const next = idx >= 0 && idx < steps.length - 1 ? steps[idx + 1] : null;
|
|
18
21
|
const prevSlug = prev ? parseStepId(prev.id).stepSlug : null;
|
|
19
22
|
const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
20
23
|
---
|
|
21
|
-
<nav class="step-nav" data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
|
|
24
|
+
<nav class:list={["step-nav", !next && "step-nav-final"]} data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
|
|
22
25
|
<div>
|
|
23
26
|
{prev && (
|
|
24
27
|
<a class="sn-prev" href={withBase(`/${tutorialSlug}/${prevSlug}`)}>
|
|
@@ -26,15 +29,18 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
|
26
29
|
</a>
|
|
27
30
|
)}
|
|
28
31
|
</div>
|
|
29
|
-
<div>
|
|
32
|
+
<div class="sn-slot">
|
|
30
33
|
{next ? (
|
|
31
34
|
<a class="sn-next" href={withBase(`/${tutorialSlug}/${nextSlug}`)} data-next-link="true">
|
|
32
35
|
{hasCheckpoint ? "Continue" : "Next"}: {next.data.title} →
|
|
33
36
|
</a>
|
|
34
37
|
) : (
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
<TutorialCompletion
|
|
39
|
+
client:load
|
|
40
|
+
tutorialSlug={tutorialSlug}
|
|
41
|
+
totalSteps={steps.length}
|
|
42
|
+
nextTutorial={nextTutorial}
|
|
43
|
+
/>
|
|
38
44
|
)}
|
|
39
45
|
</div>
|
|
40
46
|
</nav>
|
|
@@ -83,11 +89,20 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
|
83
89
|
.step-nav {
|
|
84
90
|
display: flex;
|
|
85
91
|
justify-content: space-between;
|
|
92
|
+
align-items: flex-start;
|
|
86
93
|
gap: 1rem;
|
|
87
94
|
margin-top: 3rem;
|
|
88
95
|
padding-top: 1.5rem;
|
|
89
96
|
border-top: var(--border-default, 2px) solid var(--color-border);
|
|
90
97
|
}
|
|
98
|
+
.step-nav-final {
|
|
99
|
+
display: grid;
|
|
100
|
+
grid-template-columns: minmax(0, auto) minmax(18rem, 1fr);
|
|
101
|
+
}
|
|
102
|
+
.sn-slot {
|
|
103
|
+
display: flex;
|
|
104
|
+
justify-content: flex-end;
|
|
105
|
+
}
|
|
91
106
|
.sn-prev, .sn-next {
|
|
92
107
|
display: inline-block;
|
|
93
108
|
padding: 0.7rem 1rem;
|
|
@@ -105,4 +120,78 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
|
105
120
|
.sn-next { background: var(--color-accent); color: var(--color-accent-fg); border-color: var(--color-accent); }
|
|
106
121
|
.sn-next:hover:not(.is-disabled) { color: var(--color-accent-fg); }
|
|
107
122
|
.sn-next.is-disabled { opacity: 0.4; cursor: not-allowed; }
|
|
123
|
+
:global(.tutorial-completion) {
|
|
124
|
+
width: min(100%, 42rem);
|
|
125
|
+
display: grid;
|
|
126
|
+
gap: 0.9rem;
|
|
127
|
+
}
|
|
128
|
+
:global(.completion-status),
|
|
129
|
+
:global(.completion-card) {
|
|
130
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
131
|
+
background: var(--color-surface);
|
|
132
|
+
padding: 1rem;
|
|
133
|
+
}
|
|
134
|
+
:global(.tutorial-completion.is-complete .completion-status) {
|
|
135
|
+
border-color: var(--color-success, var(--color-accent));
|
|
136
|
+
box-shadow: var(--shadow-raised);
|
|
137
|
+
}
|
|
138
|
+
:global(.completion-kicker),
|
|
139
|
+
:global(.completion-card-label),
|
|
140
|
+
:global(.completion-meta) {
|
|
141
|
+
display: block;
|
|
142
|
+
font-family: var(--font-mono);
|
|
143
|
+
font-size: 0.75rem;
|
|
144
|
+
text-transform: uppercase;
|
|
145
|
+
letter-spacing: 0.06em;
|
|
146
|
+
color: var(--color-muted);
|
|
147
|
+
}
|
|
148
|
+
:global(.completion-status h2) {
|
|
149
|
+
margin: 0.3rem 0 0.25rem;
|
|
150
|
+
font-size: 1.25rem;
|
|
151
|
+
line-height: var(--leading-heading, 1.2);
|
|
152
|
+
}
|
|
153
|
+
:global(.completion-status p),
|
|
154
|
+
:global(.completion-card span) {
|
|
155
|
+
margin: 0;
|
|
156
|
+
color: var(--color-muted);
|
|
157
|
+
}
|
|
158
|
+
:global(.completion-actions) {
|
|
159
|
+
display: grid;
|
|
160
|
+
gap: 0.75rem;
|
|
161
|
+
}
|
|
162
|
+
:global(.completion-card) {
|
|
163
|
+
display: grid;
|
|
164
|
+
gap: 0.4rem;
|
|
165
|
+
color: var(--color-fg);
|
|
166
|
+
text-decoration: none;
|
|
167
|
+
}
|
|
168
|
+
:global(a.completion-card:hover) {
|
|
169
|
+
border-color: var(--color-accent);
|
|
170
|
+
transform: translate(-2px, -2px);
|
|
171
|
+
box-shadow: var(--shadow-raised);
|
|
172
|
+
}
|
|
173
|
+
:global(.completion-card-primary) {
|
|
174
|
+
border-color: var(--color-accent);
|
|
175
|
+
}
|
|
176
|
+
:global(.completion-card strong) {
|
|
177
|
+
font-size: 1.05rem;
|
|
178
|
+
}
|
|
179
|
+
:global(.tutorial-completion.is-locked .completion-status) {
|
|
180
|
+
opacity: 0.48;
|
|
181
|
+
filter: grayscale(1);
|
|
182
|
+
cursor: not-allowed;
|
|
183
|
+
}
|
|
184
|
+
@media (max-width: 760px) {
|
|
185
|
+
.step-nav,
|
|
186
|
+
.step-nav-final {
|
|
187
|
+
display: grid;
|
|
188
|
+
grid-template-columns: 1fr;
|
|
189
|
+
}
|
|
190
|
+
.sn-slot {
|
|
191
|
+
justify-content: stretch;
|
|
192
|
+
}
|
|
193
|
+
:global(.tutorial-completion) {
|
|
194
|
+
width: 100%;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
108
197
|
</style>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { withBase } from "../lib/base";
|
|
2
|
+
import { useProgress } from "../lib/progress/useProgress";
|
|
3
|
+
import type { TutorialSummary } from "../lib/tutorialSummary";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
tutorialSlug: string;
|
|
7
|
+
totalSteps: number;
|
|
8
|
+
nextTutorial?: TutorialSummary;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countCompletedSteps(steps: Record<string, unknown>, tutorialSlug: string): number {
|
|
12
|
+
return Object.entries(steps).filter(
|
|
13
|
+
([key, value]) => key.startsWith(`${tutorialSlug}/`) && value === "complete",
|
|
14
|
+
).length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function TutorialCompletion({ tutorialSlug, totalSteps, nextTutorial }: Props) {
|
|
18
|
+
const { state } = useProgress();
|
|
19
|
+
const completedSteps = countCompletedSteps(state.steps, tutorialSlug);
|
|
20
|
+
const isComplete = totalSteps > 0 && completedSteps >= totalSteps;
|
|
21
|
+
const progressLabel = `${Math.min(completedSteps, totalSteps)} / ${totalSteps} steps complete`;
|
|
22
|
+
|
|
23
|
+
if (!isComplete) {
|
|
24
|
+
return (
|
|
25
|
+
<section className="tutorial-completion is-locked" aria-label="Tutorial completion">
|
|
26
|
+
<div className="completion-status" aria-disabled="true">
|
|
27
|
+
<span className="completion-kicker">Almost done</span>
|
|
28
|
+
<h2>Complete the remaining checkpoints to finish.</h2>
|
|
29
|
+
<p>
|
|
30
|
+
{progressLabel}
|
|
31
|
+
{nextTutorial ? ` Then you can continue to ${nextTutorial.title}.` : ""}
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section className="tutorial-completion is-complete" aria-label="Tutorial completion">
|
|
40
|
+
<div className="completion-status">
|
|
41
|
+
<span className="completion-kicker">Tutorial complete</span>
|
|
42
|
+
<h2>You completed every step.</h2>
|
|
43
|
+
<p>{progressLabel}</p>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="completion-actions">
|
|
46
|
+
{nextTutorial ? (
|
|
47
|
+
<a
|
|
48
|
+
className="completion-card completion-card-primary"
|
|
49
|
+
href={withBase(`/${nextTutorial.slug}`)}
|
|
50
|
+
>
|
|
51
|
+
<span className="completion-card-label">Continue learning</span>
|
|
52
|
+
<strong>{nextTutorial.title}</strong>
|
|
53
|
+
<span>{nextTutorial.description}</span>
|
|
54
|
+
<span className="completion-meta">
|
|
55
|
+
{nextTutorial.difficulty}
|
|
56
|
+
{nextTutorial.duration ? ` | ${nextTutorial.duration}` : ""}
|
|
57
|
+
</span>
|
|
58
|
+
</a>
|
|
59
|
+
) : (
|
|
60
|
+
<a className="completion-card completion-card-primary" href={withBase("/")}>
|
|
61
|
+
<span className="completion-card-label">Browse tutorials</span>
|
|
62
|
+
<strong>Pick your next tutorial</strong>
|
|
63
|
+
<span>Browse the catalog and choose what to build next.</span>
|
|
64
|
+
</a>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</section>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -6,30 +6,126 @@
|
|
|
6
6
|
* `Astro.request.headers` access at render time, no warning when a
|
|
7
7
|
* prerendered route happens to include BaseLayout.
|
|
8
8
|
*/
|
|
9
|
+
import { withBase } from "../../lib/base";
|
|
9
10
|
import UserMenuIsland from "./UserMenu.tsx";
|
|
10
11
|
|
|
12
|
+
// Shared with UserMenu.tsx's readAuthSnapshot/writeAuthSnapshot — the
|
|
13
|
+
// inline script below reads this key before paint to swap the static
|
|
14
|
+
// fallback to the cached signed-in state, removing the nav reload flash.
|
|
15
|
+
const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
|
|
16
|
+
const TOKENS_HREF = withBase("/settings/tokens");
|
|
17
|
+
|
|
11
18
|
const GITHUB_ICON_PATH =
|
|
12
19
|
"M12 .5C5.65.5.5 5.65 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";
|
|
13
20
|
---
|
|
14
21
|
<div class="user-menu-shell">
|
|
15
22
|
<div class="user-menu um-fallback" data-user-menu-fallback aria-hidden="true">
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
{/* Signed-out placeholder. Shown by default; the inline script below
|
|
24
|
+
hides it when a cached signed-in snapshot exists. */}
|
|
25
|
+
<span class="um-fallback-variant" data-um-fallback-signedout>
|
|
26
|
+
<button type="button" class="um-btn" disabled tabindex="-1">
|
|
27
|
+
<svg
|
|
28
|
+
class="um-gh"
|
|
29
|
+
viewBox="0 0 24 24"
|
|
30
|
+
width="14"
|
|
31
|
+
height="14"
|
|
32
|
+
fill="currentColor"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<path d={GITHUB_ICON_PATH} />
|
|
36
|
+
</svg>
|
|
37
|
+
<span>Sign in with GitHub</span>
|
|
38
|
+
</button>
|
|
39
|
+
</span>
|
|
40
|
+
{/* Signed-in placeholder. Hidden until the inline script fills in the
|
|
41
|
+
cached avatar + name and reveals it. Mirrors the island's
|
|
42
|
+
signed-in layout so the swap on hydration is invisible. */}
|
|
43
|
+
<span class="um-fallback-variant" data-um-fallback-signedin hidden>
|
|
44
|
+
<img class="um-avatar" alt="" data-um-avatar hidden />
|
|
45
|
+
<span class="um-avatar um-avatar-fallback" aria-hidden="true" data-um-initial hidden></span>
|
|
46
|
+
<span class="um-name" data-um-name></span>
|
|
47
|
+
<a class="um-btn um-mcp-btn" href={TOKENS_HREF} tabindex="-1">
|
|
48
|
+
<svg
|
|
49
|
+
viewBox="0 0 24 24"
|
|
50
|
+
width="14"
|
|
51
|
+
height="14"
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
stroke-width="2"
|
|
55
|
+
stroke-linecap="round"
|
|
56
|
+
stroke-linejoin="round"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
>
|
|
59
|
+
<circle cx="7.5" cy="15.5" r="4.5" />
|
|
60
|
+
<path d="M11 12L20 3l1.5 1.5L20 6l1.5 1.5L19 9l-1.5-1.5L16 9" />
|
|
61
|
+
</svg>
|
|
62
|
+
<span>MCP setup</span>
|
|
63
|
+
</a>
|
|
64
|
+
<button type="button" class="um-btn um-btn-icon" disabled tabindex="-1">
|
|
65
|
+
<svg
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
width="14"
|
|
68
|
+
height="14"
|
|
69
|
+
fill="none"
|
|
70
|
+
stroke="currentColor"
|
|
71
|
+
stroke-width="2"
|
|
72
|
+
stroke-linecap="round"
|
|
73
|
+
stroke-linejoin="round"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
>
|
|
76
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
77
|
+
<polyline points="16 17 21 12 16 7" />
|
|
78
|
+
<line x1="21" y1="12" x2="9" y2="12" />
|
|
79
|
+
</svg>
|
|
80
|
+
<span class="sr-only">Sign out</span>
|
|
81
|
+
</button>
|
|
82
|
+
</span>
|
|
29
83
|
</div>
|
|
30
84
|
<UserMenuIsland client:only="react" />
|
|
31
85
|
</div>
|
|
32
86
|
|
|
87
|
+
{/* Pre-paint: read the cached session snapshot and, if signed in, swap
|
|
88
|
+
the static fallback to an avatar + name placeholder before the first
|
|
89
|
+
frame. Runs inline (blocking) so there's no flash of "Sign in with
|
|
90
|
+
GitHub" for returning signed-in users. */}
|
|
91
|
+
<script is:inline define:vars={{ snapshotKey: AUTH_SNAPSHOT_KEY }}>
|
|
92
|
+
(function () {
|
|
93
|
+
try {
|
|
94
|
+
var raw = window.localStorage.getItem(snapshotKey);
|
|
95
|
+
if (!raw) return;
|
|
96
|
+
var user = (JSON.parse(raw) || {}).user;
|
|
97
|
+
if (!user) return; // signed out → keep the default placeholder
|
|
98
|
+
var root = document.querySelector("[data-user-menu-fallback]");
|
|
99
|
+
if (!root) return;
|
|
100
|
+
var signedOut = root.querySelector("[data-um-fallback-signedout]");
|
|
101
|
+
var signedIn = root.querySelector("[data-um-fallback-signedin]");
|
|
102
|
+
if (!signedOut || !signedIn) return;
|
|
103
|
+
|
|
104
|
+
var label = user.name || user.email || "Signed in";
|
|
105
|
+
var first = String(label).trim().split(/\s+/)[0] || "Signed in";
|
|
106
|
+
var nameEl = signedIn.querySelector("[data-um-name]");
|
|
107
|
+
if (nameEl) {
|
|
108
|
+
nameEl.textContent = first;
|
|
109
|
+
nameEl.setAttribute("title", label);
|
|
110
|
+
}
|
|
111
|
+
var avatar = signedIn.querySelector("[data-um-avatar]");
|
|
112
|
+
var initial = signedIn.querySelector("[data-um-initial]");
|
|
113
|
+
if (user.image && avatar) {
|
|
114
|
+
avatar.src = user.image;
|
|
115
|
+
avatar.alt = label;
|
|
116
|
+
avatar.hidden = false;
|
|
117
|
+
} else if (initial) {
|
|
118
|
+
initial.textContent = label.trim().charAt(0).toUpperCase();
|
|
119
|
+
initial.hidden = false;
|
|
120
|
+
}
|
|
121
|
+
signedOut.hidden = true;
|
|
122
|
+
signedIn.hidden = false;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
/* ignore — fall back to the default signed-out placeholder */
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
</script>
|
|
128
|
+
|
|
33
129
|
<style is:global>
|
|
34
130
|
.user-menu-shell {
|
|
35
131
|
display: grid;
|
|
@@ -76,6 +172,14 @@ const GITHUB_ICON_PATH =
|
|
|
76
172
|
.um-fallback[hidden] {
|
|
77
173
|
display: none;
|
|
78
174
|
}
|
|
175
|
+
/* Variants flow their children directly into the .user-menu flex row
|
|
176
|
+
(so the parent's gap applies), and collapse fully when hidden. */
|
|
177
|
+
.um-fallback-variant {
|
|
178
|
+
display: contents;
|
|
179
|
+
}
|
|
180
|
+
.um-fallback-variant[hidden] {
|
|
181
|
+
display: none;
|
|
182
|
+
}
|
|
79
183
|
.um-btn {
|
|
80
184
|
display: inline-flex;
|
|
81
185
|
align-items: center;
|
|
@@ -27,15 +27,72 @@ interface Session {
|
|
|
27
27
|
user?: SessionUser;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Key for the client-side session snapshot. Shared with the inline
|
|
31
|
+
// pre-paint script in UserMenu.astro — keep both in sync.
|
|
32
|
+
const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
|
|
33
|
+
|
|
34
|
+
// Read the last-known session synchronously so the island can paint the
|
|
35
|
+
// correct menu on its very first render instead of waiting a network
|
|
36
|
+
// round-trip (the source of the nav "reload flash"). Returns:
|
|
37
|
+
// - `undefined` → never resolved on this device; render nothing yet
|
|
38
|
+
// - `null` → last known to be signed out
|
|
39
|
+
// - Session → last known signed-in user
|
|
40
|
+
function readAuthSnapshot(): Session | null | undefined {
|
|
41
|
+
if (typeof window === "undefined") return undefined;
|
|
42
|
+
try {
|
|
43
|
+
const raw = window.localStorage.getItem(AUTH_SNAPSHOT_KEY);
|
|
44
|
+
if (!raw) return undefined;
|
|
45
|
+
const parsed = JSON.parse(raw) as { user?: SessionUser | null };
|
|
46
|
+
return parsed?.user ? { user: parsed.user } : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Persist a minimal, non-sensitive snapshot (name/email/image) so the
|
|
53
|
+
// next page load can render optimistically. Stores `{ user: null }` when
|
|
54
|
+
// signed out so a returning signed-out user also skips the loading gap.
|
|
55
|
+
function writeAuthSnapshot(session: Session | null) {
|
|
56
|
+
if (typeof window === "undefined") return;
|
|
57
|
+
try {
|
|
58
|
+
const user = session?.user
|
|
59
|
+
? {
|
|
60
|
+
name: session.user.name ?? null,
|
|
61
|
+
email: session.user.email ?? null,
|
|
62
|
+
image: session.user.image ?? null,
|
|
63
|
+
}
|
|
64
|
+
: null;
|
|
65
|
+
window.localStorage.setItem(AUTH_SNAPSHOT_KEY, JSON.stringify({ user }));
|
|
66
|
+
} catch {
|
|
67
|
+
/* storage unavailable (private mode, quota) — degrade to no cache */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearAuthSnapshot() {
|
|
72
|
+
if (typeof window === "undefined") return;
|
|
73
|
+
try {
|
|
74
|
+
window.localStorage.removeItem(AUTH_SNAPSHOT_KEY);
|
|
75
|
+
} catch {
|
|
76
|
+
/* storage unavailable (private mode, quota) — degrade to no cache */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
30
80
|
const GITHUB_ICON_PATH =
|
|
31
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";
|
|
32
82
|
|
|
33
83
|
export default function UserMenu() {
|
|
34
84
|
// `undefined` = not yet loaded; `null` = no auth or signed out;
|
|
35
|
-
// object = signed in.
|
|
85
|
+
// object = signed in. Seeded from the last-known snapshot so a
|
|
86
|
+
// returning user paints the right menu immediately, then revalidated
|
|
87
|
+
// by the fetch below. The tri-state avoids flashing the sign-in
|
|
36
88
|
// button while the session fetch is in flight.
|
|
37
|
-
const [session, setSession] = useState<Session | null | undefined>(
|
|
89
|
+
const [session, setSession] = useState<Session | null | undefined>(readAuthSnapshot);
|
|
38
90
|
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
|
91
|
+
// Flips true once the network fetch settles (success, 404, or error).
|
|
92
|
+
// Lets us tell a *cached* signed-out state (keep the static fallback
|
|
93
|
+
// visible until we know more) apart from a *resolved* not-wired state
|
|
94
|
+
// (hide the fallback so Tier-1 scaffolds show no dead UI).
|
|
95
|
+
const [resolved, setResolved] = useState(false);
|
|
39
96
|
|
|
40
97
|
useEffect(() => {
|
|
41
98
|
let cancelled = false;
|
|
@@ -50,16 +107,23 @@ export default function UserMenu() {
|
|
|
50
107
|
if (!sessRes.ok || !csrfRes.ok) {
|
|
51
108
|
setSession(null);
|
|
52
109
|
setCsrfToken(null);
|
|
110
|
+
setResolved(true);
|
|
111
|
+
clearAuthSnapshot();
|
|
53
112
|
return;
|
|
54
113
|
}
|
|
55
114
|
const sess = (await sessRes.json()) as Session | null;
|
|
56
115
|
const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
|
|
57
|
-
|
|
116
|
+
const nextSession = sess?.user ? sess : null;
|
|
117
|
+
setSession(nextSession);
|
|
58
118
|
setCsrfToken(csrf?.csrfToken ?? null);
|
|
119
|
+
setResolved(true);
|
|
120
|
+
writeAuthSnapshot(nextSession);
|
|
59
121
|
} catch {
|
|
60
122
|
if (!cancelled) {
|
|
61
123
|
setSession(null);
|
|
62
124
|
setCsrfToken(null);
|
|
125
|
+
setResolved(true);
|
|
126
|
+
clearAuthSnapshot();
|
|
63
127
|
}
|
|
64
128
|
}
|
|
65
129
|
})();
|
|
@@ -69,16 +133,28 @@ export default function UserMenu() {
|
|
|
69
133
|
}, []);
|
|
70
134
|
|
|
71
135
|
useEffect(() => {
|
|
72
|
-
|
|
136
|
+
// Only retire the static fallback once the island has something to
|
|
137
|
+
// show in its place (a known user or the wired sign-in form) or the
|
|
138
|
+
// fetch has resolved with nothing to show (Tier-1). Until then, keep
|
|
139
|
+
// the server-rendered fallback so the nav never goes blank.
|
|
140
|
+
const islandHasContent = Boolean(session?.user) || Boolean(csrfToken);
|
|
141
|
+
if (!islandHasContent && !resolved) return;
|
|
73
142
|
document.querySelectorAll<HTMLElement>("[data-user-menu-fallback]").forEach((el) => {
|
|
74
143
|
el.hidden = true;
|
|
75
144
|
});
|
|
76
|
-
}, [session]);
|
|
77
|
-
|
|
78
|
-
// Loading or auth not wired → render nothing.
|
|
79
|
-
if (session === undefined || !csrfToken) return null;
|
|
145
|
+
}, [session, csrfToken, resolved]);
|
|
80
146
|
|
|
81
147
|
const user = session?.user;
|
|
148
|
+
|
|
149
|
+
// Render nothing until we either know a signed-in user (from the cache
|
|
150
|
+
// or the fetch) or have confirmed auth is wired (csrf token present).
|
|
151
|
+
// A cached user paints immediately — that's what kills the reload
|
|
152
|
+
// flash. Withholding the signed-out form until csrf loads keeps
|
|
153
|
+
// Tier-1 scaffolds — where the auth endpoints 404, so csrf stays
|
|
154
|
+
// null — from surfacing a dead "Sign in with GitHub" button, and
|
|
155
|
+
// avoids a tokenless form before the round-trip completes.
|
|
156
|
+
if (!user && !csrfToken) return null;
|
|
157
|
+
|
|
82
158
|
const callbackUrl = typeof window !== "undefined" ? window.location.href : withBase("/");
|
|
83
159
|
|
|
84
160
|
// Compact label for the topbar: first word of `name`, falling back
|
|
@@ -127,10 +203,19 @@ export default function UserMenu() {
|
|
|
127
203
|
</svg>
|
|
128
204
|
<span>MCP setup</span>
|
|
129
205
|
</a>
|
|
130
|
-
<form
|
|
131
|
-
|
|
206
|
+
<form
|
|
207
|
+
method="post"
|
|
208
|
+
action={withBase("/api/auth/signout")}
|
|
209
|
+
onSubmit={() => writeAuthSnapshot(null)}
|
|
210
|
+
>
|
|
211
|
+
<input type="hidden" name="csrfToken" value={csrfToken ?? ""} />
|
|
132
212
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
133
|
-
<button
|
|
213
|
+
<button
|
|
214
|
+
type="submit"
|
|
215
|
+
className="um-btn um-btn-icon"
|
|
216
|
+
title="Sign out"
|
|
217
|
+
disabled={!csrfToken}
|
|
218
|
+
>
|
|
134
219
|
<svg
|
|
135
220
|
viewBox="0 0 24 24"
|
|
136
221
|
width="14"
|
|
@@ -152,9 +237,9 @@ export default function UserMenu() {
|
|
|
152
237
|
</>
|
|
153
238
|
) : (
|
|
154
239
|
<form method="post" action={withBase("/api/auth/signin/github")}>
|
|
155
|
-
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
240
|
+
<input type="hidden" name="csrfToken" value={csrfToken ?? ""} />
|
|
156
241
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
157
|
-
<button type="submit" className="um-btn">
|
|
242
|
+
<button type="submit" className="um-btn" disabled={!csrfToken}>
|
|
158
243
|
<svg
|
|
159
244
|
className="um-gh"
|
|
160
245
|
viewBox="0 0 24 24"
|
|
@@ -3,11 +3,18 @@ interface Props {
|
|
|
3
3
|
src: string;
|
|
4
4
|
title?: string;
|
|
5
5
|
aspect?: string;
|
|
6
|
-
type?: "iframe" | "video";
|
|
6
|
+
type?: "iframe" | "video" | "slides";
|
|
7
|
+
slide?: string | number;
|
|
7
8
|
}
|
|
8
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
src,
|
|
11
|
+
title = "Embedded content",
|
|
12
|
+
aspect = "16/9",
|
|
13
|
+
type = "iframe",
|
|
14
|
+
slide,
|
|
15
|
+
} = Astro.props;
|
|
9
16
|
|
|
10
|
-
function
|
|
17
|
+
function normalizeIframe(url: string): string {
|
|
11
18
|
try {
|
|
12
19
|
const u = new URL(url, "https://example.com");
|
|
13
20
|
if (u.hostname.endsWith("youtube.com")) {
|
|
@@ -20,10 +27,53 @@ function normalize(url: string): string {
|
|
|
20
27
|
}
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
// Google Slides jumps to a slide via `slide=id.p<n>` for a numeric position, or
|
|
31
|
+
// a raw object id (`id.g123abc`) passed through as-is.
|
|
32
|
+
function googleSlideParam(value: string | number): string {
|
|
33
|
+
return typeof value === "number" ? `id.p${value}` : value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeSlides(url: string, start?: string | number): string {
|
|
37
|
+
let u: URL;
|
|
38
|
+
try {
|
|
39
|
+
u = new URL(url);
|
|
40
|
+
} catch {
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
43
|
+
const isGoogleSlides =
|
|
44
|
+
u.hostname.endsWith("docs.google.com") && u.pathname.includes("/presentation/");
|
|
45
|
+
if (isGoogleSlides) {
|
|
46
|
+
u.pathname = u.pathname.replace(/\/(edit|pub|present|preview|view)$/, "/embed");
|
|
47
|
+
if (!u.pathname.endsWith("/embed")) {
|
|
48
|
+
u.pathname = `${u.pathname.replace(/\/$/, "")}/embed`;
|
|
49
|
+
}
|
|
50
|
+
// The editor URL carries the slide in a `#slide=...` fragment; the embed
|
|
51
|
+
// player reads it from the query string instead.
|
|
52
|
+
const fragmentSlide = u.hash.match(/slide=([^&]+)/);
|
|
53
|
+
if (fragmentSlide && !u.searchParams.has("slide")) {
|
|
54
|
+
u.searchParams.set("slide", fragmentSlide[1]);
|
|
55
|
+
}
|
|
56
|
+
u.hash = "";
|
|
57
|
+
}
|
|
58
|
+
if (start !== undefined) {
|
|
59
|
+
u.searchParams.set("slide", isGoogleSlides ? googleSlideParam(start) : String(start));
|
|
60
|
+
}
|
|
61
|
+
return u.toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isVideo = type === "video";
|
|
65
|
+
const finalSrc = isVideo
|
|
66
|
+
? src
|
|
67
|
+
: type === "slides"
|
|
68
|
+
? normalizeSlides(src, slide)
|
|
69
|
+
: normalizeIframe(src);
|
|
24
70
|
---
|
|
25
71
|
<div class="embed" style={`aspect-ratio: ${aspect};`}>
|
|
26
|
-
{
|
|
72
|
+
{isVideo ? (
|
|
73
|
+
<video controls preload="metadata">
|
|
74
|
+
<source src={finalSrc} />
|
|
75
|
+
</video>
|
|
76
|
+
) : (
|
|
27
77
|
<iframe
|
|
28
78
|
src={finalSrc}
|
|
29
79
|
title={title}
|
|
@@ -32,10 +82,6 @@ const finalSrc = type === "iframe" ? normalize(src) : src;
|
|
|
32
82
|
referrerpolicy="strict-origin-when-cross-origin"
|
|
33
83
|
allowfullscreen
|
|
34
84
|
/>
|
|
35
|
-
) : (
|
|
36
|
-
<video controls preload="metadata">
|
|
37
|
-
<source src={finalSrc} />
|
|
38
|
-
</video>
|
|
39
85
|
)}
|
|
40
86
|
</div>
|
|
41
87
|
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,12 @@ export {
|
|
|
51
51
|
} from "./lib/progress/useProgress.ts";
|
|
52
52
|
// Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
|
|
53
53
|
export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
|
|
54
|
+
export {
|
|
55
|
+
createTutorialSummary,
|
|
56
|
+
type TutorialDifficulty,
|
|
57
|
+
type TutorialSummary,
|
|
58
|
+
type TutorialSummaryInput,
|
|
59
|
+
} from "./lib/tutorialSummary.ts";
|
|
54
60
|
|
|
55
61
|
// AI config type (consumers provide concrete values; framework consumes shape).
|
|
56
62
|
export type { AiConfig } from "./types/ai.ts";
|
|
@@ -5,6 +5,7 @@ import StepHeroMedia from "../components/StepHeroMedia.astro";
|
|
|
5
5
|
import StepNav from "../components/StepNav.astro";
|
|
6
6
|
import { parseStepId } from "../lib/content.ts";
|
|
7
7
|
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
8
|
+
import type { TutorialSummary } from "../lib/tutorialSummary";
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
tutorial: TutorialEntry;
|
|
@@ -21,6 +22,7 @@ interface Props {
|
|
|
21
22
|
repoUrl?: string;
|
|
22
23
|
siteUrl?: string;
|
|
23
24
|
siteCreditLabel?: string;
|
|
25
|
+
nextTutorial?: TutorialSummary;
|
|
24
26
|
/** Set false to drop the built-in footer and supply your own. */
|
|
25
27
|
showFooter?: boolean;
|
|
26
28
|
}
|
|
@@ -40,6 +42,7 @@ const {
|
|
|
40
42
|
repoUrl,
|
|
41
43
|
siteUrl,
|
|
42
44
|
siteCreditLabel,
|
|
45
|
+
nextTutorial,
|
|
43
46
|
showFooter = true,
|
|
44
47
|
} = Astro.props;
|
|
45
48
|
|
|
@@ -117,6 +120,7 @@ const trackBootstrap =
|
|
|
117
120
|
currentStepSlug={currentStepSlug}
|
|
118
121
|
gated={tutorial.data.gated}
|
|
119
122
|
hasCheckpoint={hasCheckpoint}
|
|
123
|
+
nextTutorial={nextTutorial}
|
|
120
124
|
/>
|
|
121
125
|
|
|
122
126
|
{tutorial.data.feedbackUrl && (
|
package/src/lib/heroMedia.ts
CHANGED
|
@@ -30,6 +30,16 @@ export function createHeroMediaSchema(
|
|
|
30
30
|
caption: schema.string().min(1).optional(),
|
|
31
31
|
})
|
|
32
32
|
.strict(),
|
|
33
|
+
schema
|
|
34
|
+
.object({
|
|
35
|
+
kind: schema.literal("slides"),
|
|
36
|
+
src: schema.string().min(1),
|
|
37
|
+
title: schema.string().min(1),
|
|
38
|
+
aspect: schema.string().min(1).default("16/9"),
|
|
39
|
+
slide: schema.union([schema.string().min(1), schema.number()]).optional(),
|
|
40
|
+
caption: schema.string().min(1).optional(),
|
|
41
|
+
})
|
|
42
|
+
.strict(),
|
|
33
43
|
]);
|
|
34
44
|
}
|
|
35
45
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type TutorialDifficulty = "beginner" | "intermediate" | "advanced";
|
|
2
|
+
|
|
3
|
+
export interface TutorialSummaryInput {
|
|
4
|
+
id: string;
|
|
5
|
+
data: {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
difficulty: TutorialDifficulty;
|
|
9
|
+
estimatedDuration?: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TutorialSummary {
|
|
14
|
+
slug: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
difficulty: TutorialDifficulty;
|
|
18
|
+
duration?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createTutorialSummary(
|
|
22
|
+
tutorial: TutorialSummaryInput,
|
|
23
|
+
summedDuration?: string,
|
|
24
|
+
): TutorialSummary {
|
|
25
|
+
return {
|
|
26
|
+
slug: tutorial.id,
|
|
27
|
+
title: tutorial.data.title,
|
|
28
|
+
description: tutorial.data.description,
|
|
29
|
+
difficulty: tutorial.data.difficulty,
|
|
30
|
+
duration: tutorial.data.estimatedDuration ?? summedDuration,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -5,11 +5,12 @@ import ChatButton from "../components/ai/ChatButton.tsx";
|
|
|
5
5
|
import OpenInAgent from "../components/ai/OpenInAgent.tsx";
|
|
6
6
|
import SelectionAsk from "../components/ai/SelectionAsk.tsx";
|
|
7
7
|
import StepHelp from "../components/ai/StepHelp.tsx";
|
|
8
|
-
import { parseStepId } from "../lib/content.ts";
|
|
8
|
+
import { getStepsForTutorial, getTutorialBySlug, parseStepId, sumDurations } from "../lib/content.ts";
|
|
9
9
|
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
10
10
|
import { mdxComponents } from "../lib/mdx-components.ts";
|
|
11
11
|
import { buildContext } from "../lib/ai/context.ts";
|
|
12
12
|
import { emptyState } from "../lib/progress/types.ts";
|
|
13
|
+
import { createTutorialSummary } from "../lib/tutorialSummary.ts";
|
|
13
14
|
import type { AiConfig } from "../types/ai.ts";
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
@@ -51,6 +52,13 @@ const { Content } = await render(currentStep);
|
|
|
51
52
|
|
|
52
53
|
const components = mdxComponents();
|
|
53
54
|
const hasCheckpoint = currentStep.body?.includes("<Checkpoint") ?? false;
|
|
55
|
+
const nextTutorial = tutorial.data.nextTutorial
|
|
56
|
+
? await getTutorialBySlug(tutorial.data.nextTutorial)
|
|
57
|
+
: undefined;
|
|
58
|
+
const nextTutorialSteps = nextTutorial ? await getStepsForTutorial(nextTutorial.id) : [];
|
|
59
|
+
const nextTutorialSummary = nextTutorial
|
|
60
|
+
? createTutorialSummary(nextTutorial, sumDurations(nextTutorialSteps))
|
|
61
|
+
: undefined;
|
|
54
62
|
|
|
55
63
|
const aiConfig: AiConfig = { ...aiDefaults, ...(tutorial.data.ai ?? {}) };
|
|
56
64
|
const initialContext = buildContext({
|
|
@@ -77,6 +85,7 @@ const initialContext = buildContext({
|
|
|
77
85
|
repoUrl={repoUrl}
|
|
78
86
|
siteUrl={siteUrl}
|
|
79
87
|
siteCreditLabel={siteCreditLabel}
|
|
88
|
+
nextTutorial={nextTutorialSummary}
|
|
80
89
|
showFooter={showFooter}
|
|
81
90
|
>
|
|
82
91
|
<slot name="head" slot="head" />
|
|
@@ -12,41 +12,27 @@
|
|
|
12
12
|
display: inline-flex;
|
|
13
13
|
align-items: center;
|
|
14
14
|
gap: 0.5rem;
|
|
15
|
-
padding: 0.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
22
|
-
color: var(--color-accent-fg);
|
|
23
|
-
border: 0;
|
|
15
|
+
padding: 0.55rem 0.9rem;
|
|
16
|
+
/* Ghost / outlined style: transparent surface so the button reads as a
|
|
17
|
+
* quiet affordance rather than a primary CTA. */
|
|
18
|
+
background: color-mix(in oklab, var(--color-surface) 70%, transparent);
|
|
19
|
+
color: var(--color-fg);
|
|
20
|
+
border: var(--border-default) solid color-mix(in oklab, var(--color-border) 80%, var(--color-accent));
|
|
24
21
|
font-weight: 600;
|
|
25
22
|
cursor: pointer;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
box-shadow:
|
|
29
|
-
inset 0 1px 0 color-mix(in srgb, white 25%, transparent),
|
|
30
|
-
0 0 0 1px color-mix(in oklab, var(--color-accent) 60%, var(--color-fg)),
|
|
31
|
-
0 6px 20px color-mix(in oklab, var(--color-accent) 35%, transparent);
|
|
32
|
-
transition: transform 0.12s ease, box-shadow 0.12s ease, background-position 0.3s ease;
|
|
33
|
-
background-size: 140% 140%;
|
|
34
|
-
background-position: 0% 0%;
|
|
23
|
+
backdrop-filter: blur(6px);
|
|
24
|
+
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
|
35
25
|
}
|
|
36
26
|
.chat-fab:hover {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
inset 0 1px 0 color-mix(in srgb, white 30%, transparent),
|
|
41
|
-
0 0 0 1px color-mix(in oklab, var(--color-accent) 70%, var(--color-fg)),
|
|
42
|
-
0 10px 26px color-mix(in oklab, var(--color-accent) 50%, transparent);
|
|
27
|
+
color: var(--color-accent);
|
|
28
|
+
border-color: var(--color-accent);
|
|
29
|
+
background: color-mix(in oklab, var(--color-accent) 10%, var(--color-surface));
|
|
43
30
|
}
|
|
44
31
|
.chat-fab:active {
|
|
45
|
-
|
|
32
|
+
background: color-mix(in oklab, var(--color-accent) 16%, var(--color-surface));
|
|
46
33
|
}
|
|
47
34
|
.chat-fab :is(svg) {
|
|
48
|
-
|
|
49
|
-
filter: drop-shadow(0 1px 0 color-mix(in srgb, black 30%, transparent));
|
|
35
|
+
color: var(--color-accent);
|
|
50
36
|
}
|
|
51
37
|
|
|
52
38
|
.chat-panel {
|