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,97 @@
|
|
|
1
|
+
import type { StepEntry, TutorialEntry } from "../content";
|
|
2
|
+
import { parseStepId } from "../content";
|
|
3
|
+
import type { ProgressState } from "../progress/types";
|
|
4
|
+
|
|
5
|
+
export interface AssistantContext {
|
|
6
|
+
tutorial: {
|
|
7
|
+
slug: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
difficulty: string;
|
|
11
|
+
tags: string[];
|
|
12
|
+
};
|
|
13
|
+
outline: Array<{ slug: string; title: string; completed: boolean; current: boolean }>;
|
|
14
|
+
currentStep: {
|
|
15
|
+
slug: string;
|
|
16
|
+
title: string;
|
|
17
|
+
source: string;
|
|
18
|
+
};
|
|
19
|
+
priorSteps: Array<{ slug: string; title: string; source: string }>;
|
|
20
|
+
progress: {
|
|
21
|
+
completed: string[];
|
|
22
|
+
quizzes: Array<{ id: string; correct: boolean }>;
|
|
23
|
+
checkpoints: string[];
|
|
24
|
+
};
|
|
25
|
+
references: Array<{ source: string; content: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface BuildOptions {
|
|
29
|
+
tutorial: TutorialEntry;
|
|
30
|
+
steps: StepEntry[];
|
|
31
|
+
currentStep: StepEntry;
|
|
32
|
+
progress: ProgressState;
|
|
33
|
+
references?: Array<{ source: string; content: string }>;
|
|
34
|
+
includeFutureSteps?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Assemble the per-request context. Prior-step bodies are inlined verbatim
|
|
39
|
+
* so the assistant has the same view of the work as the learner. Future
|
|
40
|
+
* steps are excluded by default to avoid spoilers.
|
|
41
|
+
*/
|
|
42
|
+
export function buildContext({
|
|
43
|
+
tutorial,
|
|
44
|
+
steps,
|
|
45
|
+
currentStep,
|
|
46
|
+
progress,
|
|
47
|
+
references = [],
|
|
48
|
+
includeFutureSteps = false,
|
|
49
|
+
}: BuildOptions): AssistantContext {
|
|
50
|
+
const slug = tutorial.id;
|
|
51
|
+
const currentIdx = steps.findIndex((s) => s.id === currentStep.id);
|
|
52
|
+
const { stepSlug: currentSlug } = parseStepId(currentStep.id);
|
|
53
|
+
|
|
54
|
+
const outline = steps.map((s) => {
|
|
55
|
+
const { stepSlug } = parseStepId(s.id);
|
|
56
|
+
return {
|
|
57
|
+
slug: stepSlug,
|
|
58
|
+
title: s.data.title,
|
|
59
|
+
completed: progress.steps[`${slug}/${stepSlug}`] === "complete",
|
|
60
|
+
current: stepSlug === currentSlug,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const priorSteps = steps
|
|
65
|
+
.slice(0, includeFutureSteps ? steps.length : currentIdx)
|
|
66
|
+
.filter((s) => s.id !== currentStep.id)
|
|
67
|
+
.map((s) => ({
|
|
68
|
+
slug: parseStepId(s.id).stepSlug,
|
|
69
|
+
title: s.data.title,
|
|
70
|
+
source: s.body ?? "",
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
tutorial: {
|
|
75
|
+
slug,
|
|
76
|
+
title: tutorial.data.title,
|
|
77
|
+
description: tutorial.data.description,
|
|
78
|
+
difficulty: tutorial.data.difficulty,
|
|
79
|
+
tags: tutorial.data.tags,
|
|
80
|
+
},
|
|
81
|
+
outline,
|
|
82
|
+
currentStep: {
|
|
83
|
+
slug: currentSlug,
|
|
84
|
+
title: currentStep.data.title,
|
|
85
|
+
source: currentStep.body ?? "",
|
|
86
|
+
},
|
|
87
|
+
priorSteps,
|
|
88
|
+
progress: {
|
|
89
|
+
completed: Object.entries(progress.steps)
|
|
90
|
+
.filter(([k, v]) => k.startsWith(`${slug}/`) && v === "complete")
|
|
91
|
+
.map(([k]) => k.split("/").slice(1).join("/")),
|
|
92
|
+
quizzes: Object.entries(progress.quizzes).map(([id, q]) => ({ id, correct: q.correct })),
|
|
93
|
+
checkpoints: Object.keys(progress.checkpoints),
|
|
94
|
+
},
|
|
95
|
+
references,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type CollectionEntry, getCollection } from "astro:content";
|
|
2
|
+
|
|
3
|
+
export type TutorialEntry = CollectionEntry<"tutorials">;
|
|
4
|
+
export type StepEntry = CollectionEntry<"steps">;
|
|
5
|
+
|
|
6
|
+
const STEP_PREFIX = /^(\d+)-(.+)$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a step's collection id ("react-todo/01-setup") into its parts.
|
|
10
|
+
* Tutorial slug is the verbatim first path segment; step ordering comes
|
|
11
|
+
* from the file's numeric prefix.
|
|
12
|
+
*/
|
|
13
|
+
export function parseStepId(id: string): { tutorialSlug: string; stepSlug: string; order: number } {
|
|
14
|
+
const slash = id.indexOf("/");
|
|
15
|
+
if (slash < 0) {
|
|
16
|
+
throw new Error(`Unrecognized step id: ${id}`);
|
|
17
|
+
}
|
|
18
|
+
const tutorialSlug = id.slice(0, slash);
|
|
19
|
+
const stepFile = id.slice(slash + 1);
|
|
20
|
+
if (!tutorialSlug || !stepFile) {
|
|
21
|
+
throw new Error(`Unrecognized step id: ${id}`);
|
|
22
|
+
}
|
|
23
|
+
const stepMatch = STEP_PREFIX.exec(stepFile);
|
|
24
|
+
return {
|
|
25
|
+
tutorialSlug,
|
|
26
|
+
stepSlug: stepMatch?.[2] ?? stepFile,
|
|
27
|
+
order: stepMatch?.[1] ? Number.parseInt(stepMatch[1], 10) : 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getTutorials(): Promise<TutorialEntry[]> {
|
|
32
|
+
const all = await getCollection("tutorials");
|
|
33
|
+
return all.sort((a, b) => {
|
|
34
|
+
const ao = (a.data as { order?: number }).order ?? 0;
|
|
35
|
+
const bo = (b.data as { order?: number }).order ?? 0;
|
|
36
|
+
return ao - bo;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getTutorialBySlug(slug: string): Promise<TutorialEntry | undefined> {
|
|
41
|
+
const all = await getCollection("tutorials");
|
|
42
|
+
return all.find((t) => t.id === slug);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getStepsForTutorial(slug: string): Promise<StepEntry[]> {
|
|
46
|
+
const all = await getCollection("steps");
|
|
47
|
+
return all
|
|
48
|
+
.filter((s) => parseStepId(s.id).tutorialSlug === slug)
|
|
49
|
+
.sort((a, b) => parseStepId(a.id).order - parseStepId(b.id).order);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getStep(
|
|
53
|
+
tutorialSlug: string,
|
|
54
|
+
stepSlug: string,
|
|
55
|
+
): Promise<StepEntry | undefined> {
|
|
56
|
+
const steps = await getStepsForTutorial(tutorialSlug);
|
|
57
|
+
return steps.find((s) => parseStepId(s.id).stepSlug === stepSlug);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* "5 min" + "3 min" → "8 min". Falls back to undefined if any step is missing duration.
|
|
62
|
+
*/
|
|
63
|
+
export function sumDurations(steps: StepEntry[]): string | undefined {
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const step of steps) {
|
|
66
|
+
const dur = step.data.duration;
|
|
67
|
+
if (!dur) return undefined;
|
|
68
|
+
const match = /(\d+)\s*min/i.exec(dur);
|
|
69
|
+
if (!match?.[1]) return undefined;
|
|
70
|
+
total += Number.parseInt(match[1], 10);
|
|
71
|
+
}
|
|
72
|
+
return total > 0 ? `${total} min` : undefined;
|
|
73
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Callout from "../components/mdx/Callout.astro";
|
|
2
|
+
import Checkpoint from "../components/mdx/Checkpoint.astro";
|
|
3
|
+
import Diff from "../components/mdx/Diff.astro";
|
|
4
|
+
import Download from "../components/mdx/Download.astro";
|
|
5
|
+
import Embed from "../components/mdx/Embed.astro";
|
|
6
|
+
import File from "../components/mdx/File.astro";
|
|
7
|
+
import FileTree from "../components/mdx/FileTree.astro";
|
|
8
|
+
import Hint from "../components/mdx/Hint.astro";
|
|
9
|
+
import Mermaid from "../components/mdx/Mermaid.astro";
|
|
10
|
+
import Playground from "../components/mdx/Playground.astro";
|
|
11
|
+
import Quiz from "../components/mdx/Quiz.astro";
|
|
12
|
+
import Recap from "../components/mdx/Recap.astro";
|
|
13
|
+
import Reveal from "../components/mdx/Reveal.astro";
|
|
14
|
+
import StepCmp from "../components/mdx/Step.astro";
|
|
15
|
+
import StepsCmp from "../components/mdx/Steps.astro";
|
|
16
|
+
import Tab from "../components/mdx/Tab.astro";
|
|
17
|
+
import Tabs from "../components/mdx/Tabs.astro";
|
|
18
|
+
import Terminal from "../components/mdx/Terminal.astro";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The components map passed to <Content components={...} />. Every React
|
|
22
|
+
* island has an .astro wrapper that pre-binds the hydration directive
|
|
23
|
+
* (Astro's MDX render call can't apply client:* itself; the wrapper does).
|
|
24
|
+
* Checkpoint reads its host route from a DOM marker, not props.
|
|
25
|
+
*/
|
|
26
|
+
export function mdxComponents() {
|
|
27
|
+
return {
|
|
28
|
+
Callout,
|
|
29
|
+
Hint,
|
|
30
|
+
Steps: StepsCmp,
|
|
31
|
+
Step: StepCmp,
|
|
32
|
+
File,
|
|
33
|
+
Recap,
|
|
34
|
+
Embed,
|
|
35
|
+
Download,
|
|
36
|
+
Tabs,
|
|
37
|
+
Tab,
|
|
38
|
+
FileTree,
|
|
39
|
+
Reveal,
|
|
40
|
+
Terminal,
|
|
41
|
+
Mermaid,
|
|
42
|
+
Diff,
|
|
43
|
+
Quiz,
|
|
44
|
+
Checkpoint,
|
|
45
|
+
Playground,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createRemoteStore } from "./remote";
|
|
2
|
+
import {
|
|
3
|
+
CHANNEL_NAME,
|
|
4
|
+
emptyState,
|
|
5
|
+
type ProgressState,
|
|
6
|
+
type ProgressStore,
|
|
7
|
+
STORAGE_KEY,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
const isBrowser = typeof window !== "undefined";
|
|
11
|
+
|
|
12
|
+
function readStorage(): ProgressState {
|
|
13
|
+
if (!isBrowser) return emptyState();
|
|
14
|
+
try {
|
|
15
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
if (!raw) return emptyState();
|
|
17
|
+
const parsed = JSON.parse(raw) as Partial<ProgressState>;
|
|
18
|
+
return { ...emptyState(), ...parsed };
|
|
19
|
+
} catch {
|
|
20
|
+
return emptyState();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeStorage(state: ProgressState): void {
|
|
25
|
+
if (!isBrowser) return;
|
|
26
|
+
try {
|
|
27
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
28
|
+
} catch {
|
|
29
|
+
// quota or private mode — silently swallow
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createLocalStore(): ProgressStore {
|
|
34
|
+
let state: ProgressState = readStorage();
|
|
35
|
+
const subscribers = new Set<(s: ProgressState) => void>();
|
|
36
|
+
let channel: BroadcastChannel | null = null;
|
|
37
|
+
|
|
38
|
+
if (isBrowser && typeof BroadcastChannel !== "undefined") {
|
|
39
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
40
|
+
channel.addEventListener("message", (event: MessageEvent) => {
|
|
41
|
+
if (event.data?.type === "set") {
|
|
42
|
+
state = event.data.state as ProgressState;
|
|
43
|
+
for (const fn of subscribers) fn(state);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isBrowser) {
|
|
49
|
+
window.addEventListener("storage", (e) => {
|
|
50
|
+
if (e.key !== STORAGE_KEY) return;
|
|
51
|
+
state = readStorage();
|
|
52
|
+
for (const fn of subscribers) fn(state);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
get: () => state,
|
|
58
|
+
set: (updater) => {
|
|
59
|
+
state = updater(state);
|
|
60
|
+
writeStorage(state);
|
|
61
|
+
for (const fn of subscribers) fn(state);
|
|
62
|
+
channel?.postMessage({ type: "set", state });
|
|
63
|
+
},
|
|
64
|
+
subscribe: (fn) => {
|
|
65
|
+
subscribers.add(fn);
|
|
66
|
+
return () => {
|
|
67
|
+
subscribers.delete(fn);
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let singleton: ProgressStore | null = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pick the right store based on PUBLIC_PROGRESS_BACKEND. Both stores
|
|
77
|
+
* are statically imported because dynamic `require()` doesn't exist in
|
|
78
|
+
* the browser ESM bundle (this file ships to the client) and a dynamic
|
|
79
|
+
* `import()` would force every call site to become async.
|
|
80
|
+
*/
|
|
81
|
+
export function getStore(): ProgressStore {
|
|
82
|
+
if (singleton) return singleton;
|
|
83
|
+
if (isBrowser && import.meta.env.PUBLIC_PROGRESS_BACKEND === "remote") {
|
|
84
|
+
singleton = createRemoteStore();
|
|
85
|
+
} else {
|
|
86
|
+
singleton = createLocalStore();
|
|
87
|
+
}
|
|
88
|
+
return singleton;
|
|
89
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNEL_NAME,
|
|
3
|
+
emptyState,
|
|
4
|
+
type ProgressState,
|
|
5
|
+
type ProgressStore,
|
|
6
|
+
STORAGE_KEY,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
const isBrowser = typeof window !== "undefined";
|
|
10
|
+
|
|
11
|
+
type ProgressEntry = {
|
|
12
|
+
kind: string;
|
|
13
|
+
scope: string;
|
|
14
|
+
key: string;
|
|
15
|
+
value: unknown;
|
|
16
|
+
updatedAt?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const PENDING_KEY = "handzon:pending";
|
|
20
|
+
|
|
21
|
+
function readStorage(): ProgressState {
|
|
22
|
+
if (!isBrowser) return emptyState();
|
|
23
|
+
try {
|
|
24
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
25
|
+
if (!raw) return emptyState();
|
|
26
|
+
return { ...emptyState(), ...(JSON.parse(raw) as Partial<ProgressState>) };
|
|
27
|
+
} catch {
|
|
28
|
+
return emptyState();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeStorage(state: ProgressState): void {
|
|
33
|
+
if (!isBrowser) return;
|
|
34
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readPending(): ProgressEntry[] {
|
|
38
|
+
if (!isBrowser) return [];
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(window.localStorage.getItem(PENDING_KEY) ?? "[]") as ProgressEntry[];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writePending(entries: ProgressEntry[]): void {
|
|
47
|
+
window.localStorage.setItem(PENDING_KEY, JSON.stringify(entries));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
|
|
51
|
+
const out: ProgressEntry[] = [];
|
|
52
|
+
for (const [key, value] of Object.entries(next.steps)) {
|
|
53
|
+
if (prev.steps[key as `${string}/${string}`] !== value) {
|
|
54
|
+
const [scope, k] = key.split("/") as [string, string];
|
|
55
|
+
out.push({ kind: "step", scope, key: k, value });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [id, value] of Object.entries(next.quizzes)) {
|
|
59
|
+
if (JSON.stringify(prev.quizzes[id]) !== JSON.stringify(value)) {
|
|
60
|
+
out.push({ kind: "quiz", scope: "global", key: id, value });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [id, value] of Object.entries(next.checkpoints)) {
|
|
64
|
+
if (!prev.checkpoints[id]) {
|
|
65
|
+
out.push({ kind: "checkpoint", scope: "global", key: id, value });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const [k, value] of Object.entries(next.prefs)) {
|
|
69
|
+
if ((prev.prefs as Record<string, unknown>)[k] !== value) {
|
|
70
|
+
out.push({ kind: "pref", scope: "global", key: k, value });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [scope, value] of Object.entries(next.lastVisited)) {
|
|
74
|
+
const prevValue = prev.lastVisited[scope];
|
|
75
|
+
if (!prevValue || prevValue.step !== value.step || prevValue.ts !== value.ts) {
|
|
76
|
+
out.push({ kind: "lastVisited", scope, key: "step", value });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [scope, marker] of Object.entries(next.tutorials)) {
|
|
80
|
+
const prevMarker = prev.tutorials[scope] ?? {};
|
|
81
|
+
if (marker.started && prevMarker.started !== marker.started) {
|
|
82
|
+
out.push({ kind: "tutorial", scope, key: "started", value: { ts: marker.started } });
|
|
83
|
+
}
|
|
84
|
+
if (marker.completed && prevMarker.completed !== marker.completed) {
|
|
85
|
+
out.push({ kind: "tutorial", scope, key: "completed", value: { ts: marker.completed } });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Same shape as the local store, but mirrors writes to /api/progress with
|
|
93
|
+
* debounced batching and an offline queue.
|
|
94
|
+
*/
|
|
95
|
+
export function createRemoteStore(): ProgressStore {
|
|
96
|
+
let state: ProgressState = readStorage();
|
|
97
|
+
const subscribers = new Set<(s: ProgressState) => void>();
|
|
98
|
+
let channel: BroadcastChannel | null = null;
|
|
99
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
|
|
101
|
+
if (isBrowser && typeof BroadcastChannel !== "undefined") {
|
|
102
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
103
|
+
channel.addEventListener("message", (event: MessageEvent) => {
|
|
104
|
+
if (event.data?.type === "set") {
|
|
105
|
+
state = event.data.state as ProgressState;
|
|
106
|
+
for (const fn of subscribers) fn(state);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function flush() {
|
|
112
|
+
flushTimer = null;
|
|
113
|
+
const pending = readPending();
|
|
114
|
+
if (pending.length === 0) return;
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch("/api/progress", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(pending),
|
|
120
|
+
credentials: "same-origin",
|
|
121
|
+
});
|
|
122
|
+
if (res.ok) writePending([]);
|
|
123
|
+
} catch {
|
|
124
|
+
// stay offline-queued
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function scheduleFlush() {
|
|
129
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
130
|
+
flushTimer = setTimeout(flush, 750);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isBrowser) {
|
|
134
|
+
window.addEventListener("online", () => void flush());
|
|
135
|
+
|
|
136
|
+
// On first mount, pull the server's snapshot and merge in.
|
|
137
|
+
void (async () => {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch("/api/progress", { credentials: "same-origin" });
|
|
140
|
+
if (!res.ok) return;
|
|
141
|
+
const { entries } = (await res.json()) as {
|
|
142
|
+
entries: Array<{ kind: string; scope: string; key: string; value: unknown }>;
|
|
143
|
+
};
|
|
144
|
+
const merged: ProgressState = { ...emptyState(), ...state };
|
|
145
|
+
for (const e of entries) {
|
|
146
|
+
if (e.kind === "step") {
|
|
147
|
+
merged.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
|
|
148
|
+
| "incomplete"
|
|
149
|
+
| "complete";
|
|
150
|
+
} else if (e.kind === "quiz") {
|
|
151
|
+
merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
|
|
152
|
+
} else if (e.kind === "checkpoint") {
|
|
153
|
+
merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
|
|
154
|
+
} else if (e.kind === "pref") {
|
|
155
|
+
(merged.prefs as Record<string, unknown>)[e.key] = e.value;
|
|
156
|
+
} else if (e.kind === "lastVisited") {
|
|
157
|
+
// Tolerate both new ({step, ts}) and legacy (string) shapes.
|
|
158
|
+
const v = e.value as unknown;
|
|
159
|
+
merged.lastVisited[e.scope] =
|
|
160
|
+
typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
|
|
161
|
+
} else if (e.kind === "tutorial") {
|
|
162
|
+
const v = (e.value as { ts?: number }) ?? {};
|
|
163
|
+
const marker = merged.tutorials[e.scope] ?? {};
|
|
164
|
+
if (e.key === "started") marker.started = v.ts;
|
|
165
|
+
else if (e.key === "completed") marker.completed = v.ts;
|
|
166
|
+
merged.tutorials[e.scope] = marker;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
state = merged;
|
|
170
|
+
writeStorage(state);
|
|
171
|
+
for (const fn of subscribers) fn(state);
|
|
172
|
+
} catch {
|
|
173
|
+
// ignore — local data still drives the UI
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
get: () => state,
|
|
180
|
+
set: (updater) => {
|
|
181
|
+
const prev = state;
|
|
182
|
+
state = updater(state);
|
|
183
|
+
writeStorage(state);
|
|
184
|
+
const entries = diffState(prev, state);
|
|
185
|
+
if (entries.length > 0) {
|
|
186
|
+
writePending([...readPending(), ...entries]);
|
|
187
|
+
scheduleFlush();
|
|
188
|
+
}
|
|
189
|
+
for (const fn of subscribers) fn(state);
|
|
190
|
+
channel?.postMessage({ type: "set", state });
|
|
191
|
+
},
|
|
192
|
+
subscribe: (fn) => {
|
|
193
|
+
subscribers.add(fn);
|
|
194
|
+
return () => {
|
|
195
|
+
subscribers.delete(fn);
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type StepKey = `${string}/${string}`;
|
|
2
|
+
|
|
3
|
+
export interface LastVisitedEntry {
|
|
4
|
+
step: string;
|
|
5
|
+
ts: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Local mirror of the per-learner "I started / finished this tutorial"
|
|
10
|
+
* markers. We keep them locally so the remote diff is idempotent (no
|
|
11
|
+
* duplicate POSTs when the user reloads). The server's composite PK
|
|
12
|
+
* makes re-sends harmless but spamming `/api/progress` is rude.
|
|
13
|
+
*/
|
|
14
|
+
export interface TutorialMarker {
|
|
15
|
+
started?: number;
|
|
16
|
+
completed?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ProgressState = {
|
|
20
|
+
steps: Record<StepKey, "incomplete" | "complete">;
|
|
21
|
+
quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
|
|
22
|
+
checkpoints: Record<string, { ts: number }>;
|
|
23
|
+
prefs: {
|
|
24
|
+
packageManager?: "npm" | "pnpm" | "yarn" | "bun";
|
|
25
|
+
os?: "macos" | "linux" | "windows";
|
|
26
|
+
theme?: "light" | "dark";
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Per-tutorial "where was I last?" marker. Tracks `ts` so consumers
|
|
30
|
+
* (like the ResumeRail) can pick the truly most-recent tutorial
|
|
31
|
+
* instead of relying on insertion order, which lies when an existing
|
|
32
|
+
* key is overwritten.
|
|
33
|
+
*/
|
|
34
|
+
lastVisited: Record<string, LastVisitedEntry>;
|
|
35
|
+
/**
|
|
36
|
+
* Per-tutorial popularity-event markers. Cross-learner aggregates
|
|
37
|
+
* live on the server (`/api/tutorials/stats`); this map only tracks
|
|
38
|
+
* what *this* learner has emitted so we can dedupe.
|
|
39
|
+
*/
|
|
40
|
+
tutorials: Record<string, TutorialMarker>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ProgressStore {
|
|
44
|
+
get(): ProgressState;
|
|
45
|
+
set(updater: (s: ProgressState) => ProgressState): void;
|
|
46
|
+
subscribe(fn: (s: ProgressState) => void): () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const emptyState = (): ProgressState => ({
|
|
50
|
+
steps: {},
|
|
51
|
+
quizzes: {},
|
|
52
|
+
checkpoints: {},
|
|
53
|
+
prefs: {},
|
|
54
|
+
lastVisited: {},
|
|
55
|
+
tutorials: {},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// TODO: when you change ProgressState's shape in a way that's not
|
|
59
|
+
// forward-compatible with the spread-merge in readStorage(), bump the
|
|
60
|
+
// version suffix and add a one-shot migration in `local.ts` that reads
|
|
61
|
+
// the old key, transforms it, and writes the new one.
|
|
62
|
+
export const STORAGE_KEY = "handzon:v1";
|
|
63
|
+
export const CHANNEL_NAME = "handzon:v1";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { getStore } from "./local";
|
|
3
|
+
import type { ProgressState, StepKey } from "./types";
|
|
4
|
+
|
|
5
|
+
interface ProgressApi {
|
|
6
|
+
state: ProgressState;
|
|
7
|
+
markStepComplete: (tutorial: string, step: string) => void;
|
|
8
|
+
markStepIncomplete: (tutorial: string, step: string) => void;
|
|
9
|
+
recordQuiz: (questionId: string, chosen: number[], correct: boolean) => void;
|
|
10
|
+
recordCheckpoint: (checkpointId: string) => void;
|
|
11
|
+
setPref: <K extends keyof ProgressState["prefs"]>(
|
|
12
|
+
key: K,
|
|
13
|
+
value: ProgressState["prefs"][K],
|
|
14
|
+
) => void;
|
|
15
|
+
setLastVisited: (tutorial: string, step: string) => void;
|
|
16
|
+
markTutorialStarted: (tutorial: string) => void;
|
|
17
|
+
markTutorialCompleted: (tutorial: string) => void;
|
|
18
|
+
isStepComplete: (tutorial: string, step: string) => boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Action methods are memoized in a useMemo with an empty dep list so their
|
|
23
|
+
* references stay stable across renders. Without this, every render produced
|
|
24
|
+
* a new `markStepComplete` (etc.) reference; consumers using these as effect
|
|
25
|
+
* deps would re-run effects forever, each call triggering a store update and
|
|
26
|
+
* another render — a freeze-the-tab render storm.
|
|
27
|
+
*/
|
|
28
|
+
export function useProgress(): ProgressApi {
|
|
29
|
+
const store = getStore();
|
|
30
|
+
const state = useSyncExternalStore(
|
|
31
|
+
store.subscribe,
|
|
32
|
+
() => store.get(),
|
|
33
|
+
() => store.get(),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const actions = useMemo(() => {
|
|
37
|
+
const stepKey = (tutorial: string, step: string): StepKey => `${tutorial}/${step}`;
|
|
38
|
+
return {
|
|
39
|
+
markStepComplete: (tutorial: string, step: string) =>
|
|
40
|
+
store.set((s) => ({
|
|
41
|
+
...s,
|
|
42
|
+
steps: { ...s.steps, [stepKey(tutorial, step)]: "complete" as const },
|
|
43
|
+
})),
|
|
44
|
+
markStepIncomplete: (tutorial: string, step: string) =>
|
|
45
|
+
store.set((s) => ({
|
|
46
|
+
...s,
|
|
47
|
+
steps: { ...s.steps, [stepKey(tutorial, step)]: "incomplete" as const },
|
|
48
|
+
})),
|
|
49
|
+
recordQuiz: (questionId: string, chosen: number[], correct: boolean) =>
|
|
50
|
+
store.set((s) => ({
|
|
51
|
+
...s,
|
|
52
|
+
quizzes: { ...s.quizzes, [questionId]: { chosen, correct, ts: Date.now() } },
|
|
53
|
+
})),
|
|
54
|
+
recordCheckpoint: (checkpointId: string) =>
|
|
55
|
+
store.set((s) => ({
|
|
56
|
+
...s,
|
|
57
|
+
checkpoints: { ...s.checkpoints, [checkpointId]: { ts: Date.now() } },
|
|
58
|
+
})),
|
|
59
|
+
setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
|
|
60
|
+
store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
|
|
61
|
+
setLastVisited: (tutorial: string, step: string) =>
|
|
62
|
+
store.set((s) => ({
|
|
63
|
+
...s,
|
|
64
|
+
lastVisited: { ...s.lastVisited, [tutorial]: { step, ts: Date.now() } },
|
|
65
|
+
})),
|
|
66
|
+
markTutorialStarted: (tutorial: string) =>
|
|
67
|
+
store.set((s) => {
|
|
68
|
+
// Idempotent: bail if we already recorded this learner's
|
|
69
|
+
// "started" event so we don't spam /api/progress on every
|
|
70
|
+
// navigation.
|
|
71
|
+
if (s.tutorials[tutorial]?.started) return s;
|
|
72
|
+
return {
|
|
73
|
+
...s,
|
|
74
|
+
tutorials: {
|
|
75
|
+
...s.tutorials,
|
|
76
|
+
[tutorial]: { ...s.tutorials[tutorial], started: Date.now() },
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}),
|
|
80
|
+
markTutorialCompleted: (tutorial: string) =>
|
|
81
|
+
store.set((s) => {
|
|
82
|
+
if (s.tutorials[tutorial]?.completed) return s;
|
|
83
|
+
return {
|
|
84
|
+
...s,
|
|
85
|
+
tutorials: {
|
|
86
|
+
...s.tutorials,
|
|
87
|
+
[tutorial]: { ...s.tutorials[tutorial], completed: Date.now() },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
}, [store]);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
state,
|
|
96
|
+
...actions,
|
|
97
|
+
isStepComplete: (tutorial, step) =>
|
|
98
|
+
state.steps[`${tutorial}/${step}` as StepKey] === "complete",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* SSR-safe variant that only reads the store after mount. Use this when a
|
|
104
|
+
* component needs to render something only when the value is known (e.g.
|
|
105
|
+
* "Resume" buttons that should not flash on first paint).
|
|
106
|
+
*/
|
|
107
|
+
export function useProgressAfterMount(): ProgressState | null {
|
|
108
|
+
const [mounted, setMounted] = useState(false);
|
|
109
|
+
const store = getStore();
|
|
110
|
+
const state = useSyncExternalStore(
|
|
111
|
+
store.subscribe,
|
|
112
|
+
() => store.get(),
|
|
113
|
+
() => store.get(),
|
|
114
|
+
);
|
|
115
|
+
useEffect(() => setMounted(true), []);
|
|
116
|
+
return mounted ? state : null;
|
|
117
|
+
}
|