handzon-core 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/collections.ts +5 -0
- package/src/components/ai/ChatButton.tsx +1 -1
- package/src/components/ai/ChatPanel.tsx +9 -4
- package/src/components/auth/UserMenu.tsx +6 -4
- package/src/components/home/ActiveFilterChips.tsx +2 -5
- package/src/components/home/FilterBar.tsx +9 -11
- package/src/components/home/Pagination.tsx +1 -3
- package/src/components/home/ResumeRail.tsx +3 -1
- package/src/components/mdx/Checkpoint.tsx +7 -2
- package/src/components/ui/Dropdown.tsx +2 -10
- package/src/components/ui/MultiSelect.tsx +3 -17
- package/src/index.ts +22 -27
- package/src/lib/progress/remote.ts +8 -0
- package/src/lib/progress/useProgress.ts +8 -0
- package/src/pages/paths.ts +2 -1
- package/src/server/auth/config.ts +2 -1
- package/src/server/auth/schema.ts +1 -8
- package/src/server/auth.ts +1 -5
- package/src/server/db/schema.ts +1 -1
- package/src/server/handlers/progress.ts +50 -27
- package/src/server/handlers/tutorialStats.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
package/src/collections.ts
CHANGED
|
@@ -121,6 +121,11 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
|
|
|
121
121
|
estimatedDuration: z.string().optional(),
|
|
122
122
|
prerequisites: z.array(z.string()).default([]),
|
|
123
123
|
nextTutorial: z.string().optional(),
|
|
124
|
+
// TODO(handzon): `cover` and `icon` are accepted by the schema for
|
|
125
|
+
// forward compatibility, but no page currently renders them
|
|
126
|
+
// (Home cards, TutorialLanding, OG meta all ignore them). Wire them
|
|
127
|
+
// up in TutorialCard and BaseLayout's OG tags before promoting
|
|
128
|
+
// cover art in author-facing docs and skills.
|
|
124
129
|
cover: image().optional(),
|
|
125
130
|
icon: z.union([z.string(), image()]).optional(),
|
|
126
131
|
steps: z.array(z.string()).optional(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Sparkles } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import type { AiConfig } from "../../types/ai";
|
|
4
3
|
import type { AssistantContext } from "../../lib/ai/context";
|
|
4
|
+
import type { AiConfig } from "../../types/ai";
|
|
5
5
|
import ChatPanel from "./ChatPanel";
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
@@ -3,9 +3,9 @@ import { KeyRound, Send, Settings, Sparkles, Trash2, X } from "lucide-react";
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
|
-
import type { AiConfig } from "../../types/ai";
|
|
7
6
|
import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
|
|
8
7
|
import type { AssistantContext } from "../../lib/ai/context";
|
|
8
|
+
import type { AiConfig } from "../../types/ai";
|
|
9
9
|
import ByokSetup from "./ByokSetup";
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
@@ -45,6 +45,11 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
|
|
|
45
45
|
|
|
46
46
|
// Keep the latest message in view as chunks stream in (and on every
|
|
47
47
|
// send / clear). Without this, long responses scroll out of frame.
|
|
48
|
+
// The deps aren't read inside the effect — they're triggers, so the
|
|
49
|
+
// effect re-runs when a new chunk arrives or streaming flips. Biome's
|
|
50
|
+
// exhaustive-deps lint would have us remove them; that would break
|
|
51
|
+
// the autoscroll. Keep the suppression scoped to this single effect.
|
|
52
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: messages + streaming are intentional triggers
|
|
48
53
|
useEffect(() => {
|
|
49
54
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
|
50
55
|
}, [messages, streaming]);
|
|
@@ -162,8 +167,8 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
|
|
|
162
167
|
<KeyRound size={22} aria-hidden="true" />
|
|
163
168
|
<h3>API key required</h3>
|
|
164
169
|
<p>
|
|
165
|
-
{config.name} needs an API key to answer questions. Add one to get started —
|
|
166
|
-
|
|
170
|
+
{config.name} needs an API key to answer questions. Add one to get started — it's
|
|
171
|
+
stored in this browser only.
|
|
167
172
|
</p>
|
|
168
173
|
<button type="button" onClick={() => setByokOpen(true)}>
|
|
169
174
|
Set up key
|
|
@@ -197,7 +202,7 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
|
|
|
197
202
|
<div className="chat-msg chat-msg-assistant">
|
|
198
203
|
<span className="chat-role">{config.name}</span>
|
|
199
204
|
<div className="chat-content">
|
|
200
|
-
<span className="chat-thinking" aria-label="Thinking">
|
|
205
|
+
<span className="chat-thinking" role="status" aria-label="Thinking">
|
|
201
206
|
<span /> <span /> <span />
|
|
202
207
|
</span>
|
|
203
208
|
</div>
|
|
@@ -53,7 +53,7 @@ export default function UserMenu() {
|
|
|
53
53
|
}
|
|
54
54
|
const sess = (await sessRes.json()) as Session | null;
|
|
55
55
|
const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
|
|
56
|
-
setSession(sess
|
|
56
|
+
setSession(sess?.user ? sess : null);
|
|
57
57
|
setCsrfToken(csrf?.csrfToken ?? null);
|
|
58
58
|
} catch {
|
|
59
59
|
if (!cancelled) {
|
|
@@ -79,9 +79,9 @@ export default function UserMenu() {
|
|
|
79
79
|
// long-form context.
|
|
80
80
|
const fullLabel = user?.name ?? user?.email ?? "Signed in";
|
|
81
81
|
const displayName = user
|
|
82
|
-
? (user.name ? user.name.trim().split(/\s+/)[0] : null) ??
|
|
82
|
+
? ((user.name ? user.name.trim().split(/\s+/)[0] : null) ??
|
|
83
83
|
(user.email ? user.email.split("@")[0] : null) ??
|
|
84
|
-
"Signed in"
|
|
84
|
+
"Signed in")
|
|
85
85
|
: "";
|
|
86
86
|
|
|
87
87
|
return (
|
|
@@ -95,7 +95,9 @@ export default function UserMenu() {
|
|
|
95
95
|
{fullLabel.trim().charAt(0).toUpperCase()}
|
|
96
96
|
</span>
|
|
97
97
|
)}
|
|
98
|
-
<span className="um-name" title={fullLabel}>
|
|
98
|
+
<span className="um-name" title={fullLabel}>
|
|
99
|
+
{displayName}
|
|
100
|
+
</span>
|
|
99
101
|
<form method="post" action="/api/auth/signout">
|
|
100
102
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
101
103
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
@@ -40,6 +40,7 @@ export default function ActiveFilterChips({
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
|
+
// biome-ignore lint/a11y/useSemanticElements: <fieldset> requires <legend> and carries form-control semantics; this row groups filter-removal buttons.
|
|
43
44
|
<div className="active-filters" role="group" aria-label="Active filters">
|
|
44
45
|
{q && (
|
|
45
46
|
<button
|
|
@@ -79,11 +80,7 @@ export default function ActiveFilterChips({
|
|
|
79
80
|
<X size={12} aria-hidden="true" />
|
|
80
81
|
</button>
|
|
81
82
|
))}
|
|
82
|
-
<button
|
|
83
|
-
type="button"
|
|
84
|
-
className="active-filter-clear"
|
|
85
|
-
onClick={onClearAll}
|
|
86
|
-
>
|
|
83
|
+
<button type="button" className="active-filter-clear" onClick={onClearAll}>
|
|
87
84
|
Clear all
|
|
88
85
|
</button>
|
|
89
86
|
</div>
|
|
@@ -21,7 +21,12 @@ interface FilterState {
|
|
|
21
21
|
|
|
22
22
|
function parseCsv(value: string | null): Set<string> {
|
|
23
23
|
if (!value) return new Set();
|
|
24
|
-
return new Set(
|
|
24
|
+
return new Set(
|
|
25
|
+
value
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((s) => s.trim())
|
|
28
|
+
.filter(Boolean),
|
|
29
|
+
);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
function readUrlState(): FilterState {
|
|
@@ -61,11 +66,9 @@ function applyFilters(state: FilterState) {
|
|
|
61
66
|
let visible = 0;
|
|
62
67
|
cards.forEach((card) => {
|
|
63
68
|
const matchesQ = !q || card.dataset.search!.includes(q);
|
|
64
|
-
const matchesLevel =
|
|
65
|
-
state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
69
|
+
const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
66
70
|
const cardTags = (card.dataset.tags ?? "").split(",");
|
|
67
|
-
const matchesTag =
|
|
68
|
-
state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
71
|
+
const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
69
72
|
const show = matchesQ && matchesLevel && matchesTag;
|
|
70
73
|
if (show) {
|
|
71
74
|
card.removeAttribute("data-filter-hidden");
|
|
@@ -95,12 +98,7 @@ function applyFilters(state: FilterState) {
|
|
|
95
98
|
window.dispatchEvent(new CustomEvent("hz:filter-changed"));
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
export default function FilterBar({
|
|
99
|
-
difficulties,
|
|
100
|
-
tags,
|
|
101
|
-
difficultyCounts,
|
|
102
|
-
tagCounts,
|
|
103
|
-
}: Props) {
|
|
101
|
+
export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
|
|
104
102
|
const [state, setState] = useState<FilterState>(readUrlState);
|
|
105
103
|
|
|
106
104
|
useEffect(() => {
|
|
@@ -19,9 +19,7 @@ interface Props {
|
|
|
19
19
|
*/
|
|
20
20
|
function applyPagination(page: number, pageSize: number): number {
|
|
21
21
|
const visibleByFilter = Array.from(
|
|
22
|
-
document.querySelectorAll<HTMLElement>(
|
|
23
|
-
"[data-search]:not([data-filter-hidden])",
|
|
24
|
-
),
|
|
22
|
+
document.querySelectorAll<HTMLElement>("[data-search]:not([data-filter-hidden])"),
|
|
25
23
|
);
|
|
26
24
|
const start = (page - 1) * pageSize;
|
|
27
25
|
const end = start + pageSize;
|
|
@@ -44,7 +44,9 @@ export default function ResumeRail({ tutorials }: Props) {
|
|
|
44
44
|
<span className="rr-prefix">Continue</span>
|
|
45
45
|
<span className="rr-title">{mostRecent.title}</span>
|
|
46
46
|
<span className="rr-step">/ {mostRecent.step}</span>
|
|
47
|
-
<span className="rr-arrow" aria-hidden="true"
|
|
47
|
+
<span className="rr-arrow" aria-hidden="true">
|
|
48
|
+
→
|
|
49
|
+
</span>
|
|
48
50
|
</a>
|
|
49
51
|
);
|
|
50
52
|
}
|
|
@@ -27,12 +27,17 @@ function useRoute() {
|
|
|
27
27
|
export default function Checkpoint({ label, id }: Props) {
|
|
28
28
|
const reactId = useId();
|
|
29
29
|
const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
|
|
30
|
-
const { state, recordCheckpoint, markStepComplete } =
|
|
30
|
+
const { state, recordCheckpoint, removeCheckpoint, markStepComplete, markStepIncomplete } =
|
|
31
|
+
useProgress();
|
|
31
32
|
const route = useRoute();
|
|
32
33
|
const done = !!state.checkpoints[checkpointId];
|
|
33
34
|
|
|
34
35
|
function onToggle() {
|
|
35
|
-
if (done)
|
|
36
|
+
if (done) {
|
|
37
|
+
removeCheckpoint(checkpointId);
|
|
38
|
+
if (route) markStepIncomplete(route.tutorial, route.step);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
36
41
|
recordCheckpoint(checkpointId);
|
|
37
42
|
if (route) markStepComplete(route.tutorial, route.step);
|
|
38
43
|
}
|
|
@@ -65,21 +65,13 @@ export default function Dropdown<V extends string = string>({
|
|
|
65
65
|
</Select.Trigger>
|
|
66
66
|
|
|
67
67
|
<Select.Portal>
|
|
68
|
-
<Select.Content
|
|
69
|
-
className="hz-dd-content"
|
|
70
|
-
position="popper"
|
|
71
|
-
sideOffset={6}
|
|
72
|
-
>
|
|
68
|
+
<Select.Content className="hz-dd-content" position="popper" sideOffset={6}>
|
|
73
69
|
<Select.ScrollUpButton className="hz-dd-scroll">
|
|
74
70
|
<ChevronUp size={14} aria-hidden="true" />
|
|
75
71
|
</Select.ScrollUpButton>
|
|
76
72
|
<Select.Viewport className="hz-dd-viewport">
|
|
77
73
|
{options.map((opt) => (
|
|
78
|
-
<Select.Item
|
|
79
|
-
key={opt.value}
|
|
80
|
-
value={opt.value}
|
|
81
|
-
className="hz-dd-item"
|
|
82
|
-
>
|
|
74
|
+
<Select.Item key={opt.value} value={opt.value} className="hz-dd-item">
|
|
83
75
|
{opt.icon && <span className="hz-dd-icon">{opt.icon}</span>}
|
|
84
76
|
<Select.ItemText>{opt.label}</Select.ItemText>
|
|
85
77
|
<Select.ItemIndicator className="hz-dd-check">
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import * as Popover from "@radix-ui/react-popover";
|
|
2
2
|
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
3
|
-
import {
|
|
4
|
-
type KeyboardEvent,
|
|
5
|
-
type ReactNode,
|
|
6
|
-
useEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
11
4
|
|
|
12
5
|
export interface MultiSelectOption {
|
|
13
6
|
value: string;
|
|
@@ -64,10 +57,7 @@ export default function MultiSelect({
|
|
|
64
57
|
// Sort by count desc so "weighty" facets bubble up. Stable
|
|
65
58
|
// alphabetical secondary sort keeps neighbours predictable.
|
|
66
59
|
const sorted = useMemo(
|
|
67
|
-
() =>
|
|
68
|
-
[...options].sort(
|
|
69
|
-
(a, b) => b.count - a.count || a.label.localeCompare(b.label),
|
|
70
|
-
),
|
|
60
|
+
() => [...options].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
|
|
71
61
|
[options],
|
|
72
62
|
);
|
|
73
63
|
|
|
@@ -158,11 +148,7 @@ export default function MultiSelect({
|
|
|
158
148
|
</button>
|
|
159
149
|
</Popover.Trigger>
|
|
160
150
|
<Popover.Portal>
|
|
161
|
-
<Popover.Content
|
|
162
|
-
className="hz-ms-content"
|
|
163
|
-
align="start"
|
|
164
|
-
sideOffset={6}
|
|
165
|
-
>
|
|
151
|
+
<Popover.Content className="hz-ms-content" align="start" sideOffset={6}>
|
|
166
152
|
{searchable && (
|
|
167
153
|
<label className="hz-ms-search">
|
|
168
154
|
<Search size={14} aria-hidden="true" />
|
package/src/index.ts
CHANGED
|
@@ -8,48 +8,43 @@
|
|
|
8
8
|
* and types.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
// AI client (browser-side BYOK + streaming chat to handzon-ai).
|
|
12
|
+
export {
|
|
13
|
+
type ChatMessage,
|
|
14
|
+
clearLearnerKey,
|
|
15
|
+
loadLearnerKey,
|
|
16
|
+
saveLearnerKey,
|
|
17
|
+
streamChat,
|
|
18
|
+
} from "./lib/ai/client.ts";
|
|
19
|
+
export { type AssistantContext, buildContext } from "./lib/ai/context.ts";
|
|
11
20
|
// Content collection helpers (built on top of astro:content).
|
|
12
21
|
export {
|
|
13
|
-
parseStepId,
|
|
14
|
-
getTutorials,
|
|
15
|
-
getTutorialBySlug,
|
|
16
|
-
getStepsForTutorial,
|
|
17
22
|
getStep,
|
|
23
|
+
getStepsForTutorial,
|
|
24
|
+
getTutorialBySlug,
|
|
25
|
+
getTutorials,
|
|
26
|
+
parseStepId,
|
|
27
|
+
type StepEntry,
|
|
18
28
|
sumDurations,
|
|
19
29
|
type TutorialEntry,
|
|
20
|
-
type StepEntry,
|
|
21
30
|
} from "./lib/content.ts";
|
|
22
|
-
|
|
23
31
|
// MDX component map used by .astro pages rendering tutorial content.
|
|
24
32
|
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
33
|
// Progress store (localStorage + optional server sync).
|
|
41
34
|
export { getStore } from "./lib/progress/local.ts";
|
|
42
|
-
export {
|
|
43
|
-
useProgress,
|
|
44
|
-
useProgressAfterMount,
|
|
45
|
-
} from "./lib/progress/useProgress.ts";
|
|
46
35
|
export {
|
|
47
36
|
emptyState,
|
|
48
|
-
type ProgressState,
|
|
49
|
-
type StepKey,
|
|
50
37
|
type LastVisitedEntry,
|
|
38
|
+
type ProgressState,
|
|
51
39
|
type ProgressStore,
|
|
40
|
+
type StepKey,
|
|
52
41
|
} from "./lib/progress/types.ts";
|
|
42
|
+
export {
|
|
43
|
+
useProgress,
|
|
44
|
+
useProgressAfterMount,
|
|
45
|
+
} from "./lib/progress/useProgress.ts";
|
|
46
|
+
// Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
|
|
47
|
+
export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
|
|
53
48
|
|
|
54
49
|
// AI config type (consumers provide concrete values; framework consumes shape).
|
|
55
50
|
export type { AiConfig } from "./types/ai.ts";
|
|
@@ -65,6 +65,13 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
|
|
|
65
65
|
out.push({ kind: "checkpoint", scope: "global", key: id, value });
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
// Emit deletions so the server tombstones unchecked checkpoints; without
|
|
69
|
+
// this the next snapshot fetch would resurrect them from the DB.
|
|
70
|
+
for (const id of Object.keys(prev.checkpoints)) {
|
|
71
|
+
if (!next.checkpoints[id]) {
|
|
72
|
+
out.push({ kind: "checkpoint", scope: "global", key: id, value: null });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
68
75
|
for (const [k, value] of Object.entries(next.prefs)) {
|
|
69
76
|
if ((prev.prefs as Record<string, unknown>)[k] !== value) {
|
|
70
77
|
out.push({ kind: "pref", scope: "global", key: k, value });
|
|
@@ -150,6 +157,7 @@ export function createRemoteStore(): ProgressStore {
|
|
|
150
157
|
} else if (e.kind === "quiz") {
|
|
151
158
|
merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
|
|
152
159
|
} else if (e.kind === "checkpoint") {
|
|
160
|
+
if (e.value == null) continue;
|
|
153
161
|
merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
|
|
154
162
|
} else if (e.kind === "pref") {
|
|
155
163
|
(merged.prefs as Record<string, unknown>)[e.key] = e.value;
|
|
@@ -8,6 +8,7 @@ interface ProgressApi {
|
|
|
8
8
|
markStepIncomplete: (tutorial: string, step: string) => void;
|
|
9
9
|
recordQuiz: (questionId: string, chosen: number[], correct: boolean) => void;
|
|
10
10
|
recordCheckpoint: (checkpointId: string) => void;
|
|
11
|
+
removeCheckpoint: (checkpointId: string) => void;
|
|
11
12
|
setPref: <K extends keyof ProgressState["prefs"]>(
|
|
12
13
|
key: K,
|
|
13
14
|
value: ProgressState["prefs"][K],
|
|
@@ -56,6 +57,13 @@ export function useProgress(): ProgressApi {
|
|
|
56
57
|
...s,
|
|
57
58
|
checkpoints: { ...s.checkpoints, [checkpointId]: { ts: Date.now() } },
|
|
58
59
|
})),
|
|
60
|
+
removeCheckpoint: (checkpointId: string) =>
|
|
61
|
+
store.set((s) => {
|
|
62
|
+
if (!s.checkpoints[checkpointId]) return s;
|
|
63
|
+
const next = { ...s.checkpoints };
|
|
64
|
+
delete next[checkpointId];
|
|
65
|
+
return { ...s, checkpoints: next };
|
|
66
|
+
}),
|
|
59
67
|
setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
|
|
60
68
|
store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
|
|
61
69
|
setLastVisited: (tutorial: string, step: string) =>
|
package/src/pages/paths.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* export const getStaticPaths = getTutorialLandingPaths;
|
|
6
6
|
* export const getStaticPaths = getTutorialStepPaths;
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
10
|
+
import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
|
|
10
11
|
|
|
11
12
|
export async function getTutorialLandingPaths() {
|
|
12
13
|
const tutorials = await getTutorials();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* GitHub is the only provider in 0.2; email/password and others are
|
|
11
11
|
* out of scope (see plan: github-auth_d52529d5).
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import GitHub from "@auth/core/providers/github";
|
|
15
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
|
15
16
|
import { defineConfig } from "auth-astro";
|
|
16
17
|
import { accounts, sessions, users, verificationTokens } from "./schema.ts";
|
|
17
18
|
|
|
@@ -6,14 +6,7 @@
|
|
|
6
6
|
* The `learners` row that maps a signed-in user to local progress lives
|
|
7
7
|
* in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
integer,
|
|
11
|
-
pgTable,
|
|
12
|
-
primaryKey,
|
|
13
|
-
text,
|
|
14
|
-
timestamp,
|
|
15
|
-
uuid,
|
|
16
|
-
} from "drizzle-orm/pg-core";
|
|
9
|
+
import { integer, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
17
10
|
|
|
18
11
|
export const users = pgTable("users", {
|
|
19
12
|
id: uuid("id").primaryKey().defaultRandom(),
|
package/src/server/auth.ts
CHANGED
|
@@ -61,11 +61,7 @@ export async function getOrCreateLearner(
|
|
|
61
61
|
// Anonymous path — unchanged from pre-auth behaviour.
|
|
62
62
|
let deviceId = cookies.get(COOKIE)?.value;
|
|
63
63
|
if (deviceId) {
|
|
64
|
-
const found = await db
|
|
65
|
-
.select()
|
|
66
|
-
.from(learners)
|
|
67
|
-
.where(eq(learners.deviceId, deviceId))
|
|
68
|
-
.limit(1);
|
|
64
|
+
const found = await db.select().from(learners).where(eq(learners.deviceId, deviceId)).limit(1);
|
|
69
65
|
if (found[0]) return { id: found[0].id, deviceId };
|
|
70
66
|
}
|
|
71
67
|
deviceId = randomDeviceId();
|
package/src/server/db/schema.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { users } from "../auth/schema.ts";
|
|
|
13
13
|
|
|
14
14
|
// Re-export the Auth.js tables so consumers (and drizzle-kit) see one
|
|
15
15
|
// schema barrel.
|
|
16
|
-
export {
|
|
16
|
+
export { accounts, sessions, users, verificationTokens } from "../auth/schema.ts";
|
|
17
17
|
|
|
18
18
|
export const learners = pgTable(
|
|
19
19
|
"learners",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import { eq, sql } from "drizzle-orm";
|
|
2
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { getOrCreateLearner } from "../auth.ts";
|
|
5
5
|
import { getDb } from "../db/client.ts";
|
|
@@ -61,30 +61,53 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
61
61
|
const learner = await getOrCreateLearner(cookies, request);
|
|
62
62
|
const db = getDb();
|
|
63
63
|
const now = new Date();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
64
|
+
|
|
65
|
+
// `value: null` is the tombstone signal for "this entry was undone"
|
|
66
|
+
// (e.g. unchecking a checkpoint). The `value` column is NOT NULL, so
|
|
67
|
+
// we DELETE these rows instead of upserting them.
|
|
68
|
+
const deletes = parsed.filter((b) => b.value === null);
|
|
69
|
+
const upserts = parsed.filter((b) => b.value !== null);
|
|
70
|
+
|
|
71
|
+
for (const d of deletes) {
|
|
72
|
+
await db
|
|
73
|
+
.delete(progressEntries)
|
|
74
|
+
.where(
|
|
75
|
+
and(
|
|
76
|
+
eq(progressEntries.learnerId, learner.id),
|
|
77
|
+
eq(progressEntries.kind, d.kind),
|
|
78
|
+
eq(progressEntries.scope, d.scope),
|
|
79
|
+
eq(progressEntries.key, d.key),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (upserts.length > 0) {
|
|
85
|
+
const rows = upserts.map((b) => ({
|
|
86
|
+
learnerId: learner.id,
|
|
87
|
+
kind: b.kind,
|
|
88
|
+
scope: b.scope,
|
|
89
|
+
key: b.key,
|
|
90
|
+
value: b.value,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
}));
|
|
93
|
+
await db
|
|
94
|
+
.insert(progressEntries)
|
|
95
|
+
.values(rows)
|
|
96
|
+
.onConflictDoUpdate({
|
|
97
|
+
target: [
|
|
98
|
+
progressEntries.learnerId,
|
|
99
|
+
progressEntries.kind,
|
|
100
|
+
progressEntries.scope,
|
|
101
|
+
progressEntries.key,
|
|
102
|
+
],
|
|
103
|
+
set: {
|
|
104
|
+
// `excluded` is the row Postgres would have inserted — without
|
|
105
|
+
// this the SET was a no-op (`value = progress_entries.value`).
|
|
106
|
+
value: sql`excluded.value`,
|
|
107
|
+
updatedAt: sql`excluded.updated_at`,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return json({ written: parsed.length });
|
|
90
113
|
};
|
|
@@ -29,9 +29,12 @@ let cache: CacheEntry | null = null;
|
|
|
29
29
|
*/
|
|
30
30
|
export const GET: APIRoute = async () => {
|
|
31
31
|
if (!process.env.DATABASE_URL) {
|
|
32
|
-
return json(
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
return json(
|
|
33
|
+
{ stats: [] satisfies TutorialStat[] },
|
|
34
|
+
{
|
|
35
|
+
headers: { "Cache-Control": "public, max-age=60" },
|
|
36
|
+
},
|
|
37
|
+
);
|
|
35
38
|
}
|
|
36
39
|
const now = Date.now();
|
|
37
40
|
if (cache && cache.expiresAt > now) {
|