handzon-core 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +74 -0
- package/src/collections.ts +150 -0
- package/src/components/Footer.astro +85 -0
- package/src/components/Navbar.astro +74 -0
- package/src/components/Progress.tsx +36 -0
- package/src/components/Sidebar.astro +162 -0
- package/src/components/StepNav.astro +107 -0
- package/src/components/ai/ByokSetup.tsx +90 -0
- package/src/components/ai/ChatButton.tsx +30 -0
- package/src/components/ai/ChatPanel.tsx +244 -0
- package/src/components/auth/SignInButton.astro +41 -0
- package/src/components/auth/UserMenu.astro +79 -0
- package/src/components/auth/UserMenu.tsx +136 -0
- package/src/components/home/FilterBar.tsx +152 -0
- package/src/components/home/Hero.astro +60 -0
- package/src/components/home/Pagination.tsx +89 -0
- package/src/components/home/ResumeRail.tsx +50 -0
- package/src/components/home/TutorialCard.astro +185 -0
- package/src/components/mdx/Callout.astro +77 -0
- package/src/components/mdx/Checkpoint.astro +14 -0
- package/src/components/mdx/Checkpoint.tsx +49 -0
- package/src/components/mdx/Diff.astro +6 -0
- package/src/components/mdx/Diff.tsx +100 -0
- package/src/components/mdx/Download.astro +37 -0
- package/src/components/mdx/Embed.astro +56 -0
- package/src/components/mdx/File.astro +28 -0
- package/src/components/mdx/FileTree.astro +6 -0
- package/src/components/mdx/FileTree.tsx +71 -0
- package/src/components/mdx/Hint.astro +51 -0
- package/src/components/mdx/Mermaid.astro +6 -0
- package/src/components/mdx/Mermaid.tsx +47 -0
- package/src/components/mdx/Playground.astro +6 -0
- package/src/components/mdx/Playground.tsx +34 -0
- package/src/components/mdx/Quiz.astro +6 -0
- package/src/components/mdx/Quiz.tsx +102 -0
- package/src/components/mdx/Recap.astro +65 -0
- package/src/components/mdx/Reveal.astro +7 -0
- package/src/components/mdx/Reveal.tsx +25 -0
- package/src/components/mdx/Step.astro +12 -0
- package/src/components/mdx/Steps.astro +40 -0
- package/src/components/mdx/Tab.astro +22 -0
- package/src/components/mdx/Tabs.astro +67 -0
- package/src/components/mdx/Terminal.astro +6 -0
- package/src/components/mdx/Terminal.tsx +47 -0
- package/src/index.ts +55 -0
- package/src/layouts/BaseLayout.astro +112 -0
- package/src/layouts/TutorialLayout.astro +218 -0
- package/src/lib/ai/client.ts +92 -0
- package/src/lib/ai/context.ts +97 -0
- package/src/lib/content.ts +73 -0
- package/src/lib/mdx-components.ts +47 -0
- package/src/lib/progress/local.ts +89 -0
- package/src/lib/progress/remote.ts +199 -0
- package/src/lib/progress/types.ts +63 -0
- package/src/lib/progress/useProgress.ts +117 -0
- package/src/lib/rehype-mermaid-passthrough.ts +31 -0
- package/src/pages/Home.astro +408 -0
- package/src/pages/TutorialLanding.astro +324 -0
- package/src/pages/TutorialStep.astro +67 -0
- package/src/pages/paths.ts +36 -0
- package/src/server/auth/config.ts +102 -0
- package/src/server/auth/schema.ts +66 -0
- package/src/server/auth/session.ts +27 -0
- package/src/server/auth.ts +127 -0
- package/src/server/db/client.ts +14 -0
- package/src/server/db/migrate.ts +29 -0
- package/src/server/db/schema.ts +65 -0
- package/src/server/handlers/healthz.ts +6 -0
- package/src/server/handlers/progress.ts +90 -0
- package/src/server/handlers/tutorialStats.ts +67 -0
- package/src/server/http.ts +33 -0
- package/src/types/ai.ts +17 -0
- package/styles/base.css +127 -0
- package/styles/components/a11y.css +12 -0
- package/styles/components/byok.css +50 -0
- package/styles/components/chat.css +304 -0
- package/styles/components/checkpoint.css +49 -0
- package/styles/components/diff.css +44 -0
- package/styles/components/expressive-code.css +61 -0
- package/styles/components/filetree.css +68 -0
- package/styles/components/mermaid.css +19 -0
- package/styles/components/modal.css +25 -0
- package/styles/components/progress.css +19 -0
- package/styles/components/quiz.css +101 -0
- package/styles/components/reveal.css +25 -0
- package/styles/components/tabs.css +60 -0
- package/styles/components/terminal.css +55 -0
- package/styles/components.css +28 -0
- package/styles/global.css +15 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Wraps an ordered list of <Step> items.
|
|
3
|
+
---
|
|
4
|
+
<ol class="steps"><slot /></ol>
|
|
5
|
+
|
|
6
|
+
<style is:global>
|
|
7
|
+
.steps {
|
|
8
|
+
counter-reset: step;
|
|
9
|
+
list-style: none;
|
|
10
|
+
padding: 0;
|
|
11
|
+
margin: 1.5rem 0;
|
|
12
|
+
display: grid;
|
|
13
|
+
gap: 0.6rem;
|
|
14
|
+
}
|
|
15
|
+
.steps li.step {
|
|
16
|
+
counter-increment: step;
|
|
17
|
+
display: grid;
|
|
18
|
+
grid-template-columns: 2.25rem 1fr;
|
|
19
|
+
gap: 0.85rem;
|
|
20
|
+
align-items: baseline;
|
|
21
|
+
padding: 0.6rem 0.85rem;
|
|
22
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
23
|
+
background: var(--color-surface);
|
|
24
|
+
}
|
|
25
|
+
.steps li.step::before {
|
|
26
|
+
content: counter(step, decimal-leading-zero);
|
|
27
|
+
font-family: var(--font-mono);
|
|
28
|
+
font-size: 0.85em;
|
|
29
|
+
font-weight: 700;
|
|
30
|
+
color: var(--color-accent);
|
|
31
|
+
letter-spacing: 0.04em;
|
|
32
|
+
}
|
|
33
|
+
.steps li.step > * {
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
.steps li.step strong {
|
|
37
|
+
display: block;
|
|
38
|
+
margin-bottom: 0.15rem;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string;
|
|
4
|
+
}
|
|
5
|
+
const { value } = Astro.props;
|
|
6
|
+
// The Tabs script toggles hidden based on data-tab-panel === active value.
|
|
7
|
+
// First panel renders visible by default (matches Tabs.astro's initial state).
|
|
8
|
+
---
|
|
9
|
+
<div class="tut-tab-panel" data-tab-panel={value} hidden={false}>
|
|
10
|
+
<slot />
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<script define:vars={{ value }}>
|
|
14
|
+
// First-load: hide non-initial panels. Tabs' script flips hidden on activation.
|
|
15
|
+
const panels = document.querySelectorAll(`[data-tab-panel="${value}"]`);
|
|
16
|
+
panels.forEach((panel) => {
|
|
17
|
+
const root = panel.closest("[data-tabs]");
|
|
18
|
+
if (root && root.dataset.active !== value) {
|
|
19
|
+
panel.hidden = true;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface TabItem {
|
|
3
|
+
label: string;
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
items: TabItem[];
|
|
9
|
+
group?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { items, group } = Astro.props;
|
|
13
|
+
const groupAttr = group ?? `tabs-${Math.random().toString(36).slice(2, 8)}`;
|
|
14
|
+
const initial = items[0]?.value ?? "";
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<div class="tut-tabs" data-tabs data-tab-group={groupAttr} data-active={initial}>
|
|
18
|
+
<div class="tut-tabs-list" role="tablist">
|
|
19
|
+
{
|
|
20
|
+
items.map((item, i) => (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
role="tab"
|
|
24
|
+
class="tut-tabs-trigger"
|
|
25
|
+
data-tab-value={item.value}
|
|
26
|
+
data-active={i === 0 ? "true" : "false"}
|
|
27
|
+
aria-selected={i === 0}
|
|
28
|
+
>
|
|
29
|
+
{item.label}
|
|
30
|
+
</button>
|
|
31
|
+
))
|
|
32
|
+
}
|
|
33
|
+
</div>
|
|
34
|
+
<div class="tut-tabs-content">
|
|
35
|
+
<slot />
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<script>
|
|
40
|
+
const STORAGE_PREFIX = "handzon:tabs:";
|
|
41
|
+
|
|
42
|
+
function activate(root: HTMLElement, value: string) {
|
|
43
|
+
root.dataset.active = value;
|
|
44
|
+
root.querySelectorAll<HTMLElement>("[data-tab-value]").forEach((btn) => {
|
|
45
|
+
const isActive = btn.dataset.tabValue === value;
|
|
46
|
+
btn.dataset.active = isActive ? "true" : "false";
|
|
47
|
+
btn.setAttribute("aria-selected", String(isActive));
|
|
48
|
+
});
|
|
49
|
+
root.querySelectorAll<HTMLElement>("[data-tab-panel]").forEach((panel) => {
|
|
50
|
+
panel.hidden = panel.dataset.tabPanel !== value;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
document.querySelectorAll<HTMLElement>("[data-tabs]").forEach((root) => {
|
|
55
|
+
const group = root.dataset.tabGroup ?? "";
|
|
56
|
+
const storageKey = group ? `${STORAGE_PREFIX}${group}` : null;
|
|
57
|
+
const saved = storageKey ? window.localStorage.getItem(storageKey) : null;
|
|
58
|
+
if (saved) activate(root, saved);
|
|
59
|
+
root.querySelectorAll<HTMLButtonElement>("[data-tab-value]").forEach((btn) => {
|
|
60
|
+
btn.addEventListener("click", () => {
|
|
61
|
+
const v = btn.dataset.tabValue!;
|
|
62
|
+
activate(root, v);
|
|
63
|
+
if (storageKey) window.localStorage.setItem(storageKey, v);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
</script>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
type Entry =
|
|
4
|
+
| { command: string; output?: string; prompt?: string }
|
|
5
|
+
| { output: string; prompt?: never; command?: never };
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
entries: Entry[];
|
|
9
|
+
title?: string;
|
|
10
|
+
prompt?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function Terminal({ entries, title = "Terminal", prompt = "$" }: Props) {
|
|
14
|
+
const [revealed, setRevealed] = useState(entries.length);
|
|
15
|
+
|
|
16
|
+
const visible = entries.slice(0, revealed);
|
|
17
|
+
return (
|
|
18
|
+
<div className="term">
|
|
19
|
+
<div className="term-bar">
|
|
20
|
+
<span className="term-dots" aria-hidden="true">
|
|
21
|
+
<span /> <span /> <span />
|
|
22
|
+
</span>
|
|
23
|
+
<span className="term-title">{title}</span>
|
|
24
|
+
</div>
|
|
25
|
+
<pre className="term-body">
|
|
26
|
+
{visible.map((entry, i) => (
|
|
27
|
+
<div key={i}>
|
|
28
|
+
{entry.command !== undefined && (
|
|
29
|
+
<div className="term-line">
|
|
30
|
+
<span className="term-prompt">{entry.prompt ?? prompt}</span>
|
|
31
|
+
<span>{entry.command}</span>
|
|
32
|
+
</div>
|
|
33
|
+
)}
|
|
34
|
+
{entry.output !== undefined && entry.output !== "" && (
|
|
35
|
+
<div className="term-out">{entry.output}</div>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</pre>
|
|
40
|
+
{revealed < entries.length && (
|
|
41
|
+
<button type="button" className="term-run" onClick={() => setRevealed((r) => r + 1)}>
|
|
42
|
+
▶ Next
|
|
43
|
+
</button>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handzon framework — public API surface.
|
|
3
|
+
*
|
|
4
|
+
* .astro and .tsx components are NOT re-exported here; consumers import
|
|
5
|
+
* them by subpath (e.g. `import BaseLayout from "handzon-core/layouts/BaseLayout.astro"`).
|
|
6
|
+
* Astro can only resolve `.astro` files through the package's `exports`
|
|
7
|
+
* map, not through a barrel re-export, so this file ships only TS values
|
|
8
|
+
* and types.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Content collection helpers (built on top of astro:content).
|
|
12
|
+
export {
|
|
13
|
+
parseStepId,
|
|
14
|
+
getTutorials,
|
|
15
|
+
getTutorialBySlug,
|
|
16
|
+
getStepsForTutorial,
|
|
17
|
+
getStep,
|
|
18
|
+
sumDurations,
|
|
19
|
+
type TutorialEntry,
|
|
20
|
+
type StepEntry,
|
|
21
|
+
} from "./lib/content.ts";
|
|
22
|
+
|
|
23
|
+
// MDX component map used by .astro pages rendering tutorial content.
|
|
24
|
+
export { mdxComponents } from "./lib/mdx-components.ts";
|
|
25
|
+
|
|
26
|
+
// Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
|
|
27
|
+
export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
|
|
28
|
+
|
|
29
|
+
// AI client (browser-side BYOK + streaming chat to handzon-ai).
|
|
30
|
+
export {
|
|
31
|
+
streamChat,
|
|
32
|
+
loadLearnerKey,
|
|
33
|
+
saveLearnerKey,
|
|
34
|
+
clearLearnerKey,
|
|
35
|
+
type ChatMessage,
|
|
36
|
+
} from "./lib/ai/client.ts";
|
|
37
|
+
|
|
38
|
+
export { buildContext, type AssistantContext } from "./lib/ai/context.ts";
|
|
39
|
+
|
|
40
|
+
// Progress store (localStorage + optional server sync).
|
|
41
|
+
export { getStore } from "./lib/progress/local.ts";
|
|
42
|
+
export {
|
|
43
|
+
useProgress,
|
|
44
|
+
useProgressAfterMount,
|
|
45
|
+
} from "./lib/progress/useProgress.ts";
|
|
46
|
+
export {
|
|
47
|
+
emptyState,
|
|
48
|
+
type ProgressState,
|
|
49
|
+
type StepKey,
|
|
50
|
+
type LastVisitedEntry,
|
|
51
|
+
type ProgressStore,
|
|
52
|
+
} from "./lib/progress/types.ts";
|
|
53
|
+
|
|
54
|
+
// AI config type (consumers provide concrete values; framework consumes shape).
|
|
55
|
+
export type { AiConfig } from "./types/ai.ts";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Footer from "../components/Footer.astro";
|
|
3
|
+
import Navbar from "../components/Navbar.astro";
|
|
4
|
+
import UserMenu from "../components/auth/UserMenu.astro";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
siteName?: string;
|
|
10
|
+
tagline?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Top navigation mode:
|
|
13
|
+
* - "full": Navbar with logo + UserMenu (default — every page).
|
|
14
|
+
* - "userMenu": floating UserMenu only, no logo. For the homepage
|
|
15
|
+
* where the Hero already shows the brand inline.
|
|
16
|
+
* - "none": no nav at all.
|
|
17
|
+
*/
|
|
18
|
+
nav?: "full" | "userMenu" | "none";
|
|
19
|
+
/** Logo URL passed to Navbar (when nav === "full"). */
|
|
20
|
+
logoUrl?: string;
|
|
21
|
+
/** Favicon URL injected into <head>. */
|
|
22
|
+
faviconUrl?: string;
|
|
23
|
+
/** Show the site footer ("Built with Handzon" + repo link). */
|
|
24
|
+
showFooter?: boolean;
|
|
25
|
+
/** Footer link URL; defaults to the Handzon repo. */
|
|
26
|
+
repoUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Width of the page's content column. Drives the navbar + footer
|
|
29
|
+
* alignment via the --hz-page-max-width CSS custom property so the
|
|
30
|
+
* top/bottom bars sit flush with whatever the page renders. Default
|
|
31
|
+
* "80rem" matches the home grid; reader-style pages can pass a
|
|
32
|
+
* narrower value (e.g. "56rem"). Pass "none" for full-width layouts
|
|
33
|
+
* like the tutorial step (sidebar + main, no centered column).
|
|
34
|
+
*/
|
|
35
|
+
pageMaxWidth?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Horizontal padding inside the navbar + footer. Defaults to
|
|
38
|
+
* clamp(1rem, 4vw, 2rem). Use a smaller value (e.g. "1rem") when
|
|
39
|
+
* the page itself sits flush against the viewport edge — tutorial
|
|
40
|
+
* step pages do this to line up with the sidebar.
|
|
41
|
+
*/
|
|
42
|
+
pagePaddingX?: string;
|
|
43
|
+
}
|
|
44
|
+
const {
|
|
45
|
+
title,
|
|
46
|
+
description,
|
|
47
|
+
siteName = "Handzon",
|
|
48
|
+
tagline = "Hands-on tutorials",
|
|
49
|
+
nav = "full",
|
|
50
|
+
logoUrl = "/logo.svg",
|
|
51
|
+
faviconUrl = "/favicon.svg",
|
|
52
|
+
showFooter = true,
|
|
53
|
+
repoUrl,
|
|
54
|
+
// Default to a full-width navbar + footer with a small consistent
|
|
55
|
+
// inset, matching the tutorial step page. Pages with a centred
|
|
56
|
+
// column (home grid, tutorial landing) keep their own content
|
|
57
|
+
// column; the bars stay as site-wide strips.
|
|
58
|
+
pageMaxWidth = "none",
|
|
59
|
+
pagePaddingX = "1rem",
|
|
60
|
+
} = Astro.props;
|
|
61
|
+
// pageMaxWidth: "none" means no constraint — bars span the full
|
|
62
|
+
// viewport width with just the padding.
|
|
63
|
+
const resolvedMaxWidth = pageMaxWidth === "none" ? "none" : pageMaxWidth;
|
|
64
|
+
const pageTitle = title ? `${title} — ${siteName}` : siteName;
|
|
65
|
+
const desc = description ?? tagline;
|
|
66
|
+
---
|
|
67
|
+
<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8" />
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
72
|
+
<link rel="icon" type="image/svg+xml" href={faviconUrl} />
|
|
73
|
+
<title>{pageTitle}</title>
|
|
74
|
+
<meta name="description" content={desc} />
|
|
75
|
+
<meta property="og:title" content={pageTitle} />
|
|
76
|
+
<meta property="og:description" content={desc} />
|
|
77
|
+
<meta property="og:type" content="website" />
|
|
78
|
+
</head>
|
|
79
|
+
<body style={`--hz-page-max-width: ${resolvedMaxWidth}; --hz-page-padding-x: ${pagePaddingX}; --hz-nav-height: 3rem;`}>
|
|
80
|
+
{nav === "full" && (
|
|
81
|
+
<Navbar logoUrl={logoUrl} siteName={siteName} />
|
|
82
|
+
)}
|
|
83
|
+
{nav === "userMenu" && (
|
|
84
|
+
<div class="hz-topbar">
|
|
85
|
+
<UserMenu />
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
<slot />
|
|
89
|
+
{showFooter && <Footer repoUrl={repoUrl} />}
|
|
90
|
+
<script>
|
|
91
|
+
// Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
|
|
92
|
+
if (document.querySelector("pre.mermaid")) {
|
|
93
|
+
import("mermaid").then(({ default: mermaid }) => {
|
|
94
|
+
mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
|
|
95
|
+
mermaid.run({ querySelector: "pre.mermaid" });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<style is:global>
|
|
101
|
+
.hz-topbar {
|
|
102
|
+
position: fixed;
|
|
103
|
+
top: 0.75rem;
|
|
104
|
+
right: 0.9rem;
|
|
105
|
+
z-index: 50;
|
|
106
|
+
}
|
|
107
|
+
.hz-topbar:empty {
|
|
108
|
+
display: none;
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "./BaseLayout.astro";
|
|
3
|
+
import Sidebar from "../components/Sidebar.astro";
|
|
4
|
+
import StepNav from "../components/StepNav.astro";
|
|
5
|
+
import { parseStepId } from "../lib/content.ts";
|
|
6
|
+
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
tutorial: TutorialEntry;
|
|
10
|
+
steps: StepEntry[];
|
|
11
|
+
currentStep: StepEntry;
|
|
12
|
+
currentStepSlug: string;
|
|
13
|
+
hasCheckpoint?: boolean;
|
|
14
|
+
siteName?: string;
|
|
15
|
+
tagline?: string;
|
|
16
|
+
logoUrl?: string;
|
|
17
|
+
faviconUrl?: string;
|
|
18
|
+
repoUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
tutorial,
|
|
23
|
+
steps,
|
|
24
|
+
currentStep,
|
|
25
|
+
currentStepSlug,
|
|
26
|
+
hasCheckpoint = false,
|
|
27
|
+
siteName,
|
|
28
|
+
tagline,
|
|
29
|
+
logoUrl,
|
|
30
|
+
faviconUrl,
|
|
31
|
+
repoUrl,
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
|
|
34
|
+
const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
35
|
+
---
|
|
36
|
+
<BaseLayout
|
|
37
|
+
title={`${currentStep.data.title} — ${tutorial.data.title}`}
|
|
38
|
+
description={currentStep.data.summary}
|
|
39
|
+
siteName={siteName}
|
|
40
|
+
tagline={tagline}
|
|
41
|
+
logoUrl={logoUrl}
|
|
42
|
+
faviconUrl={faviconUrl}
|
|
43
|
+
repoUrl={repoUrl}
|
|
44
|
+
>
|
|
45
|
+
<div class="layout">
|
|
46
|
+
<div class="sidebar-wrap">
|
|
47
|
+
<Sidebar tutorial={tutorial} steps={steps} currentStepSlug={currentStepSlug} />
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<main class="main">
|
|
51
|
+
<header class="step-head">
|
|
52
|
+
<div class="crumb">{tutorial.data.title}</div>
|
|
53
|
+
<h1 class="step-title">{currentStep.data.title}</h1>
|
|
54
|
+
{currentStep.data.duration && (
|
|
55
|
+
<div class="step-dur">⏱ {currentStep.data.duration}</div>
|
|
56
|
+
)}
|
|
57
|
+
</header>
|
|
58
|
+
|
|
59
|
+
<article class="prose">
|
|
60
|
+
<slot />
|
|
61
|
+
</article>
|
|
62
|
+
|
|
63
|
+
<StepNav
|
|
64
|
+
tutorialSlug={tutorial.id}
|
|
65
|
+
steps={steps}
|
|
66
|
+
currentStepSlug={currentStepSlug}
|
|
67
|
+
gated={tutorial.data.gated}
|
|
68
|
+
hasCheckpoint={hasCheckpoint}
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
{tutorial.data.feedbackUrl && (
|
|
72
|
+
<footer class="step-foot">
|
|
73
|
+
<a href={tutorial.data.feedbackUrl} target="_blank" rel="noopener">
|
|
74
|
+
Report an issue with this tutorial →
|
|
75
|
+
</a>
|
|
76
|
+
</footer>
|
|
77
|
+
)}
|
|
78
|
+
</main>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div
|
|
82
|
+
id="tt-route"
|
|
83
|
+
data-tutorial-slug={tutorial.id}
|
|
84
|
+
data-step-slug={currentStepSlug}
|
|
85
|
+
data-tutorial-steps={JSON.stringify(stepSlugs)}
|
|
86
|
+
hidden
|
|
87
|
+
></div>
|
|
88
|
+
<script>
|
|
89
|
+
// Record last-visited + popularity markers from a data marker
|
|
90
|
+
// (set in the markup above). Using a normal <script> + relative
|
|
91
|
+
// import so Vite resolves the path alias correctly; define:vars +
|
|
92
|
+
// dynamic import("~/...") bypasses Vite's resolver and the browser
|
|
93
|
+
// can't load the module.
|
|
94
|
+
import { getStore } from "../lib/progress/local";
|
|
95
|
+
const route = document.getElementById("tt-route");
|
|
96
|
+
if (route) {
|
|
97
|
+
const tutorialSlug = route.dataset.tutorialSlug!;
|
|
98
|
+
const stepSlug = route.dataset.stepSlug!;
|
|
99
|
+
const tutorialSteps = JSON.parse(
|
|
100
|
+
route.dataset.tutorialSteps ?? "[]",
|
|
101
|
+
) as string[];
|
|
102
|
+
const store = getStore();
|
|
103
|
+
|
|
104
|
+
// Mark last-visited + tutorial "started" on every mount. The
|
|
105
|
+
// started branch is idempotent: it only writes the timestamp the
|
|
106
|
+
// first time per learner, so /api/progress sees one POST per
|
|
107
|
+
// tutorial — not one per page view.
|
|
108
|
+
store.set((s) => {
|
|
109
|
+
const nextLastVisited = {
|
|
110
|
+
...s.lastVisited,
|
|
111
|
+
[tutorialSlug]: { step: stepSlug, ts: Date.now() },
|
|
112
|
+
};
|
|
113
|
+
if (s.tutorials[tutorialSlug]?.started) {
|
|
114
|
+
return { ...s, lastVisited: nextLastVisited };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...s,
|
|
118
|
+
lastVisited: nextLastVisited,
|
|
119
|
+
tutorials: {
|
|
120
|
+
...s.tutorials,
|
|
121
|
+
[tutorialSlug]: {
|
|
122
|
+
...s.tutorials[tutorialSlug],
|
|
123
|
+
started: Date.now(),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Watch for tutorial completion: once every step in the embedded
|
|
130
|
+
// list flips to "complete", record the "completed" event exactly
|
|
131
|
+
// once. Re-runs on store changes so finishing the last step on a
|
|
132
|
+
// mid-tutorial page (rare) still triggers it.
|
|
133
|
+
function checkCompletion() {
|
|
134
|
+
const s = store.get();
|
|
135
|
+
if (s.tutorials[tutorialSlug]?.completed) return;
|
|
136
|
+
if (tutorialSteps.length === 0) return;
|
|
137
|
+
const allDone = tutorialSteps.every(
|
|
138
|
+
(slug) => s.steps[`${tutorialSlug}/${slug}` as `${string}/${string}`] === "complete",
|
|
139
|
+
);
|
|
140
|
+
if (allDone) {
|
|
141
|
+
store.set((cur) => ({
|
|
142
|
+
...cur,
|
|
143
|
+
tutorials: {
|
|
144
|
+
...cur.tutorials,
|
|
145
|
+
[tutorialSlug]: {
|
|
146
|
+
...cur.tutorials[tutorialSlug],
|
|
147
|
+
completed: Date.now(),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
checkCompletion();
|
|
154
|
+
store.subscribe(checkCompletion);
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
</BaseLayout>
|
|
158
|
+
|
|
159
|
+
<style>
|
|
160
|
+
/* Draw the sidebar/main divider as a background line on the grid
|
|
161
|
+
* itself: 1px-thin vertical strip at the 280px column boundary,
|
|
162
|
+
* from the top of the layout to the bottom. The grid auto-stretches
|
|
163
|
+
* to fit its content, so the line runs all the way down to the
|
|
164
|
+
* footer regardless of step length — without piggybacking on the
|
|
165
|
+
* (sticky, viewport-height) sidebar's own border. */
|
|
166
|
+
.layout {
|
|
167
|
+
display: grid;
|
|
168
|
+
grid-template-columns: 280px minmax(0, 1fr);
|
|
169
|
+
min-height: 100dvh;
|
|
170
|
+
background: linear-gradient(
|
|
171
|
+
to right,
|
|
172
|
+
transparent 280px,
|
|
173
|
+
var(--color-border) 280px,
|
|
174
|
+
var(--color-border) 281px,
|
|
175
|
+
transparent 281px
|
|
176
|
+
) no-repeat;
|
|
177
|
+
}
|
|
178
|
+
.sidebar-wrap {
|
|
179
|
+
position: sticky;
|
|
180
|
+
top: var(--hz-nav-height, 3rem);
|
|
181
|
+
height: calc(100dvh - var(--hz-nav-height, 3rem));
|
|
182
|
+
}
|
|
183
|
+
.main {
|
|
184
|
+
padding: 2rem clamp(1rem, 4vw, 3rem);
|
|
185
|
+
max-width: 80ch;
|
|
186
|
+
}
|
|
187
|
+
.crumb {
|
|
188
|
+
font-family: var(--font-mono);
|
|
189
|
+
font-size: 0.75em;
|
|
190
|
+
text-transform: uppercase;
|
|
191
|
+
letter-spacing: 0.08em;
|
|
192
|
+
color: var(--color-muted);
|
|
193
|
+
}
|
|
194
|
+
.step-title {
|
|
195
|
+
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
|
196
|
+
font-weight: 700;
|
|
197
|
+
margin: 0.3rem 0 0.5rem;
|
|
198
|
+
letter-spacing: -0.02em;
|
|
199
|
+
line-height: 1.15;
|
|
200
|
+
}
|
|
201
|
+
.step-dur {
|
|
202
|
+
font-family: var(--font-mono);
|
|
203
|
+
font-size: 0.85em;
|
|
204
|
+
color: var(--color-muted);
|
|
205
|
+
}
|
|
206
|
+
.step-head { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: var(--border-default, 2px) solid var(--color-border); }
|
|
207
|
+
.step-foot {
|
|
208
|
+
margin-top: 3rem;
|
|
209
|
+
padding-top: 1rem;
|
|
210
|
+
border-top: 1px solid var(--color-border);
|
|
211
|
+
font-size: 0.85em;
|
|
212
|
+
color: var(--color-muted);
|
|
213
|
+
}
|
|
214
|
+
@media (max-width: 900px) {
|
|
215
|
+
.layout { grid-template-columns: 1fr; }
|
|
216
|
+
.sidebar-wrap { display: none; }
|
|
217
|
+
}
|
|
218
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { AiConfig } from "../../types/ai";
|
|
2
|
+
import type { AssistantContext } from "./context";
|
|
3
|
+
|
|
4
|
+
export interface ChatMessage {
|
|
5
|
+
role: "user" | "assistant";
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ChatRequestOptions {
|
|
10
|
+
messages: ChatMessage[];
|
|
11
|
+
config: AiConfig;
|
|
12
|
+
context: AssistantContext;
|
|
13
|
+
learnerKey?: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render's `fromService.property: host` returns a bare private-network
|
|
19
|
+
* hostname (e.g. "myproject-ai-abc"). The browser needs a public URL.
|
|
20
|
+
* Treat the env value as a hostname unless it already includes a scheme
|
|
21
|
+
* — that lets local dev pass `http://localhost:4111` directly.
|
|
22
|
+
*/
|
|
23
|
+
function resolveServiceUrl(raw: string): string {
|
|
24
|
+
const trimmed = raw.replace(/\/$/, "");
|
|
25
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
26
|
+
return `https://${trimmed}.onrender.com`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* POST to the Mastra service. Reads the service URL from
|
|
31
|
+
* import.meta.env.PUBLIC_AI_SERVICE_URL at build time. Streams plain text
|
|
32
|
+
* chunks via fetch's streaming response body.
|
|
33
|
+
*/
|
|
34
|
+
export async function streamChat(opts: ChatRequestOptions): Promise<ReadableStream<string>> {
|
|
35
|
+
const rawServiceUrl = import.meta.env.PUBLIC_AI_SERVICE_URL;
|
|
36
|
+
if (!rawServiceUrl) throw new Error("PUBLIC_AI_SERVICE_URL is not set.");
|
|
37
|
+
const serviceUrl = resolveServiceUrl(rawServiceUrl);
|
|
38
|
+
|
|
39
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
40
|
+
if (opts.learnerKey) headers["X-Llm-Api-Key"] = opts.learnerKey;
|
|
41
|
+
|
|
42
|
+
const payload = {
|
|
43
|
+
tutorial: opts.context.tutorial,
|
|
44
|
+
outline: opts.context.outline,
|
|
45
|
+
currentStep: opts.context.currentStep,
|
|
46
|
+
priorSteps: opts.context.priorSteps,
|
|
47
|
+
progress: opts.context.progress,
|
|
48
|
+
references: opts.context.references,
|
|
49
|
+
allowedDomains: opts.config.allowedDomains ?? [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const res = await fetch(`${serviceUrl}/chat`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers,
|
|
55
|
+
signal: opts.signal,
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
messages: opts.messages,
|
|
58
|
+
config: {
|
|
59
|
+
provider: opts.config.provider,
|
|
60
|
+
model: opts.config.model,
|
|
61
|
+
name: opts.config.name,
|
|
62
|
+
tone: opts.config.tone,
|
|
63
|
+
persona: opts.config.persona,
|
|
64
|
+
disabledSkills: opts.config.disabledSkills,
|
|
65
|
+
},
|
|
66
|
+
payload,
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
throw new Error(`Chat service error ${res.status}: ${text}`);
|
|
73
|
+
}
|
|
74
|
+
if (!res.body) throw new Error("No response body from chat service.");
|
|
75
|
+
|
|
76
|
+
return res.body.pipeThrough(new TextDecoderStream());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const KEY_STORAGE_PREFIX = "handzon:byok:";
|
|
80
|
+
|
|
81
|
+
export function loadLearnerKey(provider: string): string | null {
|
|
82
|
+
if (typeof window === "undefined") return null;
|
|
83
|
+
return window.localStorage.getItem(`${KEY_STORAGE_PREFIX}${provider}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function saveLearnerKey(provider: string, key: string): void {
|
|
87
|
+
window.localStorage.setItem(`${KEY_STORAGE_PREFIX}${provider}`, key);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearLearnerKey(provider: string): void {
|
|
91
|
+
window.localStorage.removeItem(`${KEY_STORAGE_PREFIX}${provider}`);
|
|
92
|
+
}
|