handzon-core 0.6.1 → 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 +3 -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 +17 -8
- package/src/components/home/ActiveFilterChips.tsx +88 -0
- package/src/components/home/FilterBar.tsx +126 -77
- package/src/components/home/Pagination.tsx +1 -3
- package/src/components/home/ResumeRail.tsx +3 -1
- package/src/components/home/SortBar.tsx +65 -0
- package/src/components/home/TutorialCard.astro +57 -8
- package/src/components/mdx/Checkpoint.tsx +7 -2
- package/src/components/ui/Dropdown.tsx +91 -0
- package/src/components/ui/MultiSelect.tsx +205 -0
- package/src/index.ts +22 -27
- package/src/layouts/BaseLayout.astro +24 -1
- package/src/lib/progress/remote.ts +8 -0
- package/src/lib/progress/useProgress.ts +8 -0
- package/src/pages/Home.astro +65 -129
- 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/styles/components/dropdown.css +144 -0
- package/styles/components/filterbar.css +128 -0
- package/styles/components/multiselect.css +206 -0
- package/styles/components.css +3 -0
|
@@ -4,8 +4,14 @@ import type { TutorialEntry } from "../../lib/content";
|
|
|
4
4
|
interface Props {
|
|
5
5
|
tutorial: TutorialEntry;
|
|
6
6
|
duration?: string;
|
|
7
|
+
/** Total number of steps in this tutorial. Used as the denominator
|
|
8
|
+
* for the per-card progress bar — has to come from the server-side
|
|
9
|
+
* content collection because localStorage only holds states for the
|
|
10
|
+
* steps the user has visited (so a fresh learner who completed 1 of
|
|
11
|
+
* 4 steps would otherwise see 1/1). */
|
|
12
|
+
stepCount: number;
|
|
7
13
|
}
|
|
8
|
-
const { tutorial, duration } = Astro.props;
|
|
14
|
+
const { tutorial, duration, stepCount } = Astro.props;
|
|
9
15
|
const data = tutorial.data;
|
|
10
16
|
const slug = tutorial.id;
|
|
11
17
|
---
|
|
@@ -42,6 +48,11 @@ const slug = tutorial.id;
|
|
|
42
48
|
{duration}
|
|
43
49
|
</span>
|
|
44
50
|
)}
|
|
51
|
+
<span
|
|
52
|
+
class="card-progress"
|
|
53
|
+
data-tutorial-slug={slug}
|
|
54
|
+
data-step-count={stepCount}
|
|
55
|
+
></span>
|
|
45
56
|
</div>
|
|
46
57
|
{data.tags.length > 0 && (
|
|
47
58
|
<div class="tags">
|
|
@@ -53,7 +64,6 @@ const slug = tutorial.id;
|
|
|
53
64
|
data-tutorial-stats-slug={slug}
|
|
54
65
|
aria-hidden="true"
|
|
55
66
|
></div>
|
|
56
|
-
<div class="card-progress" data-tutorial-slug={slug}></div>
|
|
57
67
|
</div>
|
|
58
68
|
</a>
|
|
59
69
|
</article>
|
|
@@ -83,14 +93,16 @@ const slug = tutorial.id;
|
|
|
83
93
|
flex: 1;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
/* Metadata row: difficulty + duration
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
96
|
+
/* Metadata row: difficulty + duration with a divider beneath it.
|
|
97
|
+
* `margin-top: auto` pins this row (and everything below it — tags,
|
|
98
|
+
* stats, progress) to the bottom of the card so all cards in the
|
|
99
|
+
* grid share the same bottom alignment regardless of how short the
|
|
100
|
+
* description is. */
|
|
90
101
|
.badges {
|
|
91
102
|
display: flex;
|
|
92
103
|
gap: 0.4rem;
|
|
93
|
-
margin-top:
|
|
104
|
+
margin-top: auto;
|
|
105
|
+
padding-top: 0.85rem;
|
|
94
106
|
padding-bottom: 0.85rem;
|
|
95
107
|
border-bottom: 1px solid var(--color-border);
|
|
96
108
|
flex-wrap: wrap;
|
|
@@ -159,7 +171,44 @@ const slug = tutorial.id;
|
|
|
159
171
|
color: var(--color-muted);
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
|
|
174
|
+
/* Per-card completion ring, hydrated by the home-page script.
|
|
175
|
+
* Sits inside the `.badges` row, pushed to the right edge so it
|
|
176
|
+
* lines up with the level + duration chips on the same baseline. */
|
|
177
|
+
.card-progress {
|
|
178
|
+
margin-left: auto;
|
|
179
|
+
display: inline-flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 0.4rem;
|
|
182
|
+
font-family: var(--font-mono);
|
|
183
|
+
font-size: 0.68em;
|
|
184
|
+
color: var(--color-muted);
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
letter-spacing: 0.08em;
|
|
187
|
+
}
|
|
188
|
+
.card-progress:empty { display: none; }
|
|
189
|
+
/* The ring + label live inside `.card-progress` but are injected
|
|
190
|
+
* via innerHTML at runtime, so Astro's scope hash doesn't apply to
|
|
191
|
+
* them. Use :global() so the rules still match. */
|
|
192
|
+
.card-progress :global(.ring) {
|
|
193
|
+
/* Start the fill at 12 o'clock. */
|
|
194
|
+
transform: rotate(-90deg);
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
}
|
|
197
|
+
.card-progress :global(.ring-track) {
|
|
198
|
+
fill: none;
|
|
199
|
+
stroke: var(--color-border);
|
|
200
|
+
stroke-width: 2.5;
|
|
201
|
+
}
|
|
202
|
+
.card-progress :global(.ring-fill) {
|
|
203
|
+
fill: none;
|
|
204
|
+
stroke: var(--color-accent);
|
|
205
|
+
stroke-width: 2.5;
|
|
206
|
+
stroke-linecap: round;
|
|
207
|
+
transition: stroke-dashoffset 0.3s ease;
|
|
208
|
+
}
|
|
209
|
+
.card-progress :global(.ring-label) {
|
|
210
|
+
color: var(--color-accent);
|
|
211
|
+
}
|
|
163
212
|
|
|
164
213
|
/* Cross-learner popularity, hydrated from /api/tutorials/stats.
|
|
165
214
|
* Hidden until the script populates content so the card height
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as Select from "@radix-ui/react-select";
|
|
2
|
+
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export interface DropdownOption<V extends string = string> {
|
|
6
|
+
value: V;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Optional leading glyph rendered before the label inside the item. */
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DropdownProps<V extends string = string> {
|
|
13
|
+
value: V;
|
|
14
|
+
onChange: (value: V) => void;
|
|
15
|
+
options: DropdownOption<V>[];
|
|
16
|
+
/** Visible label glued to the left of the trigger (uppercase mono chip). */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Optional leading icon rendered inside the trigger before the label. */
|
|
19
|
+
triggerIcon?: ReactNode;
|
|
20
|
+
/** aria-label for the trigger when no `label` is shown. */
|
|
21
|
+
ariaLabel?: string;
|
|
22
|
+
/** Placeholder shown when `value` doesn't match any option. */
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
/** Extra class on the outer wrapper for layout tweaks. */
|
|
25
|
+
className?: string;
|
|
26
|
+
id?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Project-wide dropdown. Wraps Radix Select so we never ship the native
|
|
31
|
+
* browser dropdown UI — those look out-of-place against the brutalist
|
|
32
|
+
* mono/hard-edge palette and vary wildly across OSes. Every new select
|
|
33
|
+
* in handzon-core should use this; do not introduce raw `<select>`.
|
|
34
|
+
*/
|
|
35
|
+
export default function Dropdown<V extends string = string>({
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
options,
|
|
39
|
+
label,
|
|
40
|
+
triggerIcon,
|
|
41
|
+
ariaLabel,
|
|
42
|
+
placeholder,
|
|
43
|
+
className,
|
|
44
|
+
id,
|
|
45
|
+
}: DropdownProps<V>) {
|
|
46
|
+
return (
|
|
47
|
+
<div className={`hz-dd${className ? ` ${className}` : ""}`}>
|
|
48
|
+
{label && (
|
|
49
|
+
<span className="hz-dd-label" id={id ? `${id}-label` : undefined}>
|
|
50
|
+
{label}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
<Select.Root value={value} onValueChange={(v) => onChange(v as V)}>
|
|
54
|
+
<Select.Trigger
|
|
55
|
+
id={id}
|
|
56
|
+
className="hz-dd-trigger"
|
|
57
|
+
aria-label={ariaLabel ?? label}
|
|
58
|
+
aria-labelledby={label && id ? `${id}-label` : undefined}
|
|
59
|
+
>
|
|
60
|
+
{triggerIcon && <span className="hz-dd-tricon">{triggerIcon}</span>}
|
|
61
|
+
<Select.Value placeholder={placeholder ?? "Select…"} />
|
|
62
|
+
<Select.Icon className="hz-dd-caret">
|
|
63
|
+
<ChevronDown size={14} aria-hidden="true" />
|
|
64
|
+
</Select.Icon>
|
|
65
|
+
</Select.Trigger>
|
|
66
|
+
|
|
67
|
+
<Select.Portal>
|
|
68
|
+
<Select.Content className="hz-dd-content" position="popper" sideOffset={6}>
|
|
69
|
+
<Select.ScrollUpButton className="hz-dd-scroll">
|
|
70
|
+
<ChevronUp size={14} aria-hidden="true" />
|
|
71
|
+
</Select.ScrollUpButton>
|
|
72
|
+
<Select.Viewport className="hz-dd-viewport">
|
|
73
|
+
{options.map((opt) => (
|
|
74
|
+
<Select.Item key={opt.value} value={opt.value} className="hz-dd-item">
|
|
75
|
+
{opt.icon && <span className="hz-dd-icon">{opt.icon}</span>}
|
|
76
|
+
<Select.ItemText>{opt.label}</Select.ItemText>
|
|
77
|
+
<Select.ItemIndicator className="hz-dd-check">
|
|
78
|
+
<Check size={14} aria-hidden="true" />
|
|
79
|
+
</Select.ItemIndicator>
|
|
80
|
+
</Select.Item>
|
|
81
|
+
))}
|
|
82
|
+
</Select.Viewport>
|
|
83
|
+
<Select.ScrollDownButton className="hz-dd-scroll">
|
|
84
|
+
<ChevronDown size={14} aria-hidden="true" />
|
|
85
|
+
</Select.ScrollDownButton>
|
|
86
|
+
</Select.Content>
|
|
87
|
+
</Select.Portal>
|
|
88
|
+
</Select.Root>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
2
|
+
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
3
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export interface MultiSelectOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
count: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MultiSelectProps {
|
|
12
|
+
values: Set<string>;
|
|
13
|
+
onChange: (next: Set<string>) => void;
|
|
14
|
+
options: MultiSelectOption[];
|
|
15
|
+
/** Label chip glued to the left of the trigger (uppercase mono). */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Optional icon rendered inside the trigger. */
|
|
18
|
+
triggerIcon?: ReactNode;
|
|
19
|
+
/** Adds a "Filter values…" input inside the popover. Use for the
|
|
20
|
+
* Topics popover (long lists); skip for Level (only 3 options). */
|
|
21
|
+
searchable?: boolean;
|
|
22
|
+
/** Trigger aria-label override (falls back to `label`). */
|
|
23
|
+
ariaLabel?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Project-wide multi-select dropdown. Wraps Radix Popover so we never
|
|
29
|
+
* ship the browser-native UI and so every dropdown across the app
|
|
30
|
+
* shares the same visual chrome (mono label chip + hard-edge trigger
|
|
31
|
+
* + accent-on-focus). Pairs with Dropdown.tsx — that one handles
|
|
32
|
+
* single-select, this one handles multi.
|
|
33
|
+
*
|
|
34
|
+
* Layout: trigger summarises selection count; popover holds the full
|
|
35
|
+
* list with optional in-popover search, count badges per option, and
|
|
36
|
+
* a footer "Clear" that empties this facet (doesn't close).
|
|
37
|
+
*
|
|
38
|
+
* Keyboard: arrow keys move focus across options (roving tabIndex),
|
|
39
|
+
* Space toggles, Esc closes. Radix Popover doesn't provide list-nav,
|
|
40
|
+
* so we implement it explicitly on each row.
|
|
41
|
+
*/
|
|
42
|
+
export default function MultiSelect({
|
|
43
|
+
values,
|
|
44
|
+
onChange,
|
|
45
|
+
options,
|
|
46
|
+
label,
|
|
47
|
+
triggerIcon,
|
|
48
|
+
searchable = false,
|
|
49
|
+
ariaLabel,
|
|
50
|
+
id,
|
|
51
|
+
}: MultiSelectProps) {
|
|
52
|
+
const [open, setOpen] = useState(false);
|
|
53
|
+
const [query, setQuery] = useState("");
|
|
54
|
+
const optionRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
55
|
+
const [focusIdx, setFocusIdx] = useState(0);
|
|
56
|
+
|
|
57
|
+
// Sort by count desc so "weighty" facets bubble up. Stable
|
|
58
|
+
// alphabetical secondary sort keeps neighbours predictable.
|
|
59
|
+
const sorted = useMemo(
|
|
60
|
+
() => [...options].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
|
|
61
|
+
[options],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const filtered = useMemo(() => {
|
|
65
|
+
if (!query.trim()) return sorted;
|
|
66
|
+
const q = query.trim().toLowerCase();
|
|
67
|
+
return sorted.filter(
|
|
68
|
+
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
|
|
69
|
+
);
|
|
70
|
+
}, [sorted, query]);
|
|
71
|
+
|
|
72
|
+
// Reset the search field every time the popover closes so the next
|
|
73
|
+
// open starts clean. Keep focus index in range as `filtered` shrinks.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!open) {
|
|
76
|
+
setQuery("");
|
|
77
|
+
setFocusIdx(0);
|
|
78
|
+
}
|
|
79
|
+
}, [open]);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (focusIdx >= filtered.length) setFocusIdx(Math.max(0, filtered.length - 1));
|
|
82
|
+
}, [filtered.length, focusIdx]);
|
|
83
|
+
|
|
84
|
+
function toggle(value: string) {
|
|
85
|
+
const next = new Set(values);
|
|
86
|
+
if (next.has(value)) next.delete(value);
|
|
87
|
+
else next.add(value);
|
|
88
|
+
onChange(next);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clear() {
|
|
92
|
+
if (values.size === 0) return;
|
|
93
|
+
onChange(new Set());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function summary(): string {
|
|
97
|
+
// The label chip on the left already names the facet ("LEVEL",
|
|
98
|
+
// "TOPICS"), so the trigger body only carries the *value* — no
|
|
99
|
+
// repeat. Empty state reads "Any" (meaning "no filter applied").
|
|
100
|
+
if (values.size === 0) return "Any";
|
|
101
|
+
if (values.size === 1) {
|
|
102
|
+
const only = [...values][0];
|
|
103
|
+
const match = options.find((o) => o.value === only);
|
|
104
|
+
return match?.label ?? only;
|
|
105
|
+
}
|
|
106
|
+
return `${values.size} selected`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onOptionKey(e: KeyboardEvent<HTMLDivElement>, idx: number) {
|
|
110
|
+
if (e.key === "ArrowDown") {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
const next = Math.min(idx + 1, filtered.length - 1);
|
|
113
|
+
setFocusIdx(next);
|
|
114
|
+
optionRefs.current[next]?.focus();
|
|
115
|
+
} else if (e.key === "ArrowUp") {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
const next = Math.max(idx - 1, 0);
|
|
118
|
+
setFocusIdx(next);
|
|
119
|
+
optionRefs.current[next]?.focus();
|
|
120
|
+
} else if (e.key === " " || e.key === "Enter") {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
toggle(filtered[idx].value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
128
|
+
<Popover.Trigger asChild>
|
|
129
|
+
<button
|
|
130
|
+
id={id}
|
|
131
|
+
type="button"
|
|
132
|
+
className="hz-dd hz-ms-trigger"
|
|
133
|
+
aria-label={ariaLabel ?? label}
|
|
134
|
+
data-active={values.size > 0 || undefined}
|
|
135
|
+
>
|
|
136
|
+
<span className="hz-dd-label hz-ms-label">
|
|
137
|
+
{triggerIcon && <span className="hz-ms-label-icon">{triggerIcon}</span>}
|
|
138
|
+
<span>{label}</span>
|
|
139
|
+
</span>
|
|
140
|
+
<span className="hz-dd-trigger hz-ms-trigger-body">
|
|
141
|
+
<span className="hz-ms-value" data-empty={values.size === 0 || undefined}>
|
|
142
|
+
{summary()}
|
|
143
|
+
</span>
|
|
144
|
+
<span className="hz-dd-caret">
|
|
145
|
+
<ChevronDown size={14} aria-hidden="true" />
|
|
146
|
+
</span>
|
|
147
|
+
</span>
|
|
148
|
+
</button>
|
|
149
|
+
</Popover.Trigger>
|
|
150
|
+
<Popover.Portal>
|
|
151
|
+
<Popover.Content className="hz-ms-content" align="start" sideOffset={6}>
|
|
152
|
+
{searchable && (
|
|
153
|
+
<label className="hz-ms-search">
|
|
154
|
+
<Search size={14} aria-hidden="true" />
|
|
155
|
+
<input
|
|
156
|
+
type="search"
|
|
157
|
+
placeholder={`Filter ${label.toLowerCase()}…`}
|
|
158
|
+
value={query}
|
|
159
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
160
|
+
aria-label={`Filter ${label.toLowerCase()}`}
|
|
161
|
+
/>
|
|
162
|
+
</label>
|
|
163
|
+
)}
|
|
164
|
+
<div className="hz-ms-viewport" role="listbox" aria-multiselectable="true">
|
|
165
|
+
{filtered.length === 0 ? (
|
|
166
|
+
<div className="hz-ms-empty">No matches.</div>
|
|
167
|
+
) : (
|
|
168
|
+
filtered.map((opt, idx) => {
|
|
169
|
+
const checked = values.has(opt.value);
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
key={opt.value}
|
|
173
|
+
ref={(el) => {
|
|
174
|
+
optionRefs.current[idx] = el;
|
|
175
|
+
}}
|
|
176
|
+
role="option"
|
|
177
|
+
aria-selected={checked}
|
|
178
|
+
tabIndex={idx === focusIdx ? 0 : -1}
|
|
179
|
+
className="hz-ms-option"
|
|
180
|
+
data-checked={checked || undefined}
|
|
181
|
+
onClick={() => toggle(opt.value)}
|
|
182
|
+
onKeyDown={(e) => onOptionKey(e, idx)}
|
|
183
|
+
>
|
|
184
|
+
<span className="hz-ms-check" aria-hidden="true">
|
|
185
|
+
{checked && <Check size={12} />}
|
|
186
|
+
</span>
|
|
187
|
+
<span className="hz-ms-option-label">{opt.label}</span>
|
|
188
|
+
<span className="hz-ms-count">{opt.count}</span>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
})
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
{values.size > 0 && (
|
|
195
|
+
<div className="hz-ms-footer">
|
|
196
|
+
<button type="button" className="hz-ms-clear" onClick={clear}>
|
|
197
|
+
<X size={12} aria-hidden="true" /> Clear {label.toLowerCase()}
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</Popover.Content>
|
|
202
|
+
</Popover.Portal>
|
|
203
|
+
</Popover.Root>
|
|
204
|
+
);
|
|
205
|
+
}
|
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";
|
|
@@ -85,7 +85,11 @@ const desc = description ?? tagline;
|
|
|
85
85
|
<UserMenu />
|
|
86
86
|
</div>
|
|
87
87
|
)}
|
|
88
|
-
|
|
88
|
+
{/* Wrap the slot in a flex-growing region so the footer hugs the
|
|
89
|
+
bottom of the viewport even when the page content is short. */}
|
|
90
|
+
<div class="hz-page">
|
|
91
|
+
<slot />
|
|
92
|
+
</div>
|
|
89
93
|
{showFooter && <Footer repoUrl={repoUrl} />}
|
|
90
94
|
<script>
|
|
91
95
|
// Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
|
|
@@ -98,6 +102,25 @@ const desc = description ?? tagline;
|
|
|
98
102
|
</script>
|
|
99
103
|
|
|
100
104
|
<style is:global>
|
|
105
|
+
/* Sticky-footer layout: body is a flex column at viewport height
|
|
106
|
+
* minimum; the page-content region grows to fill, pushing the
|
|
107
|
+
* footer to the bottom when content is short. */
|
|
108
|
+
html, body {
|
|
109
|
+
min-height: 100dvh;
|
|
110
|
+
}
|
|
111
|
+
body {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
}
|
|
115
|
+
.hz-page {
|
|
116
|
+
flex: 1 0 auto;
|
|
117
|
+
display: flex;
|
|
118
|
+
flex-direction: column;
|
|
119
|
+
}
|
|
120
|
+
.hz-page > * {
|
|
121
|
+
flex: 1 0 auto;
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
.hz-topbar {
|
|
102
125
|
position: fixed;
|
|
103
126
|
top: 0.75rem;
|
|
@@ -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) =>
|