handzon-core 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { withBase } from "../lib/base";
|
|
3
3
|
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
4
4
|
import { parseStepId } from "../lib/content";
|
|
5
|
+
import { STORAGE_KEY } from "../lib/progress/types";
|
|
5
6
|
import { fallbackTextForTrack, iconForTrack } from "../lib/track-icons";
|
|
6
7
|
import Progress from "./Progress.tsx";
|
|
7
8
|
import TrackSelector from "./TrackSelector.tsx";
|
|
@@ -87,6 +88,40 @@ const slug = tutorial.id;
|
|
|
87
88
|
</ol>
|
|
88
89
|
</aside>
|
|
89
90
|
|
|
91
|
+
{/* Pre-paint: mirror the persisted progress state into the SSR sidebar
|
|
92
|
+
before hydrated modules run, avoiding a step-change flash from
|
|
93
|
+
0 / unchecked to the learner's real progress. */}
|
|
94
|
+
<script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length }}>
|
|
95
|
+
(function () {
|
|
96
|
+
try {
|
|
97
|
+
var raw = window.localStorage.getItem(storageKey);
|
|
98
|
+
var state = raw ? JSON.parse(raw) : null;
|
|
99
|
+
var steps = (state && state.steps) || {};
|
|
100
|
+
var completed = 0;
|
|
101
|
+
|
|
102
|
+
document.querySelectorAll("[data-step-key]").forEach(function (el) {
|
|
103
|
+
var key = el.getAttribute("data-step-key");
|
|
104
|
+
var done = key && steps[key] === "complete";
|
|
105
|
+
el.setAttribute("data-done", done ? "true" : "false");
|
|
106
|
+
if (done && key.indexOf(tutorialSlug + "/") === 0) completed += 1;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
var progress = document.querySelector(".sidebar .progress");
|
|
110
|
+
if (!progress) return;
|
|
111
|
+
var bounded = Math.min(completed, totalSteps);
|
|
112
|
+
var pct = totalSteps > 0 ? Math.round((bounded / totalSteps) * 100) : 0;
|
|
113
|
+
progress.setAttribute("aria-label", bounded + " of " + totalSteps + " steps complete");
|
|
114
|
+
progress.setAttribute("aria-valuenow", String(bounded));
|
|
115
|
+
var fill = progress.querySelector(".progress-fill");
|
|
116
|
+
if (fill) fill.style.width = pct + "%";
|
|
117
|
+
var label = progress.querySelector(".progress-label");
|
|
118
|
+
if (label) label.textContent = bounded + " / " + totalSteps + " steps";
|
|
119
|
+
} catch (e) {
|
|
120
|
+
/* ignore — hydrated progress will correct the sidebar */
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
</script>
|
|
124
|
+
|
|
90
125
|
<script>
|
|
91
126
|
// Hydrate per-step check marks from localStorage without an island.
|
|
92
127
|
import { getStore } from "../lib/progress/local";
|
|
@@ -6,30 +6,126 @@
|
|
|
6
6
|
* `Astro.request.headers` access at render time, no warning when a
|
|
7
7
|
* prerendered route happens to include BaseLayout.
|
|
8
8
|
*/
|
|
9
|
+
import { withBase } from "../../lib/base";
|
|
9
10
|
import UserMenuIsland from "./UserMenu.tsx";
|
|
10
11
|
|
|
12
|
+
// Shared with UserMenu.tsx's readAuthSnapshot/writeAuthSnapshot — the
|
|
13
|
+
// inline script below reads this key before paint to swap the static
|
|
14
|
+
// fallback to the cached signed-in state, removing the nav reload flash.
|
|
15
|
+
const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
|
|
16
|
+
const TOKENS_HREF = withBase("/settings/tokens");
|
|
17
|
+
|
|
11
18
|
const GITHUB_ICON_PATH =
|
|
12
19
|
"M12 .5C5.65.5.5 5.65 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
|
|
13
20
|
---
|
|
14
21
|
<div class="user-menu-shell">
|
|
15
22
|
<div class="user-menu um-fallback" data-user-menu-fallback aria-hidden="true">
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
{/* Signed-out placeholder. Shown by default; the inline script below
|
|
24
|
+
hides it when a cached signed-in snapshot exists. */}
|
|
25
|
+
<span class="um-fallback-variant" data-um-fallback-signedout>
|
|
26
|
+
<button type="button" class="um-btn" disabled tabindex="-1">
|
|
27
|
+
<svg
|
|
28
|
+
class="um-gh"
|
|
29
|
+
viewBox="0 0 24 24"
|
|
30
|
+
width="14"
|
|
31
|
+
height="14"
|
|
32
|
+
fill="currentColor"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<path d={GITHUB_ICON_PATH} />
|
|
36
|
+
</svg>
|
|
37
|
+
<span>Sign in with GitHub</span>
|
|
38
|
+
</button>
|
|
39
|
+
</span>
|
|
40
|
+
{/* Signed-in placeholder. Hidden until the inline script fills in the
|
|
41
|
+
cached avatar + name and reveals it. Mirrors the island's
|
|
42
|
+
signed-in layout so the swap on hydration is invisible. */}
|
|
43
|
+
<span class="um-fallback-variant" data-um-fallback-signedin hidden>
|
|
44
|
+
<img class="um-avatar" alt="" data-um-avatar hidden />
|
|
45
|
+
<span class="um-avatar um-avatar-fallback" aria-hidden="true" data-um-initial hidden></span>
|
|
46
|
+
<span class="um-name" data-um-name></span>
|
|
47
|
+
<a class="um-btn um-mcp-btn" href={TOKENS_HREF} tabindex="-1">
|
|
48
|
+
<svg
|
|
49
|
+
viewBox="0 0 24 24"
|
|
50
|
+
width="14"
|
|
51
|
+
height="14"
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
stroke-width="2"
|
|
55
|
+
stroke-linecap="round"
|
|
56
|
+
stroke-linejoin="round"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
>
|
|
59
|
+
<circle cx="7.5" cy="15.5" r="4.5" />
|
|
60
|
+
<path d="M11 12L20 3l1.5 1.5L20 6l1.5 1.5L19 9l-1.5-1.5L16 9" />
|
|
61
|
+
</svg>
|
|
62
|
+
<span>MCP setup</span>
|
|
63
|
+
</a>
|
|
64
|
+
<button type="button" class="um-btn um-btn-icon" disabled tabindex="-1">
|
|
65
|
+
<svg
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
width="14"
|
|
68
|
+
height="14"
|
|
69
|
+
fill="none"
|
|
70
|
+
stroke="currentColor"
|
|
71
|
+
stroke-width="2"
|
|
72
|
+
stroke-linecap="round"
|
|
73
|
+
stroke-linejoin="round"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
>
|
|
76
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
77
|
+
<polyline points="16 17 21 12 16 7" />
|
|
78
|
+
<line x1="21" y1="12" x2="9" y2="12" />
|
|
79
|
+
</svg>
|
|
80
|
+
<span class="sr-only">Sign out</span>
|
|
81
|
+
</button>
|
|
82
|
+
</span>
|
|
29
83
|
</div>
|
|
30
84
|
<UserMenuIsland client:only="react" />
|
|
31
85
|
</div>
|
|
32
86
|
|
|
87
|
+
{/* Pre-paint: read the cached session snapshot and, if signed in, swap
|
|
88
|
+
the static fallback to an avatar + name placeholder before the first
|
|
89
|
+
frame. Runs inline (blocking) so there's no flash of "Sign in with
|
|
90
|
+
GitHub" for returning signed-in users. */}
|
|
91
|
+
<script is:inline define:vars={{ snapshotKey: AUTH_SNAPSHOT_KEY }}>
|
|
92
|
+
(function () {
|
|
93
|
+
try {
|
|
94
|
+
var raw = window.localStorage.getItem(snapshotKey);
|
|
95
|
+
if (!raw) return;
|
|
96
|
+
var user = (JSON.parse(raw) || {}).user;
|
|
97
|
+
if (!user) return; // signed out → keep the default placeholder
|
|
98
|
+
var root = document.querySelector("[data-user-menu-fallback]");
|
|
99
|
+
if (!root) return;
|
|
100
|
+
var signedOut = root.querySelector("[data-um-fallback-signedout]");
|
|
101
|
+
var signedIn = root.querySelector("[data-um-fallback-signedin]");
|
|
102
|
+
if (!signedOut || !signedIn) return;
|
|
103
|
+
|
|
104
|
+
var label = user.name || user.email || "Signed in";
|
|
105
|
+
var first = String(label).trim().split(/\s+/)[0] || "Signed in";
|
|
106
|
+
var nameEl = signedIn.querySelector("[data-um-name]");
|
|
107
|
+
if (nameEl) {
|
|
108
|
+
nameEl.textContent = first;
|
|
109
|
+
nameEl.setAttribute("title", label);
|
|
110
|
+
}
|
|
111
|
+
var avatar = signedIn.querySelector("[data-um-avatar]");
|
|
112
|
+
var initial = signedIn.querySelector("[data-um-initial]");
|
|
113
|
+
if (user.image && avatar) {
|
|
114
|
+
avatar.src = user.image;
|
|
115
|
+
avatar.alt = label;
|
|
116
|
+
avatar.hidden = false;
|
|
117
|
+
} else if (initial) {
|
|
118
|
+
initial.textContent = label.trim().charAt(0).toUpperCase();
|
|
119
|
+
initial.hidden = false;
|
|
120
|
+
}
|
|
121
|
+
signedOut.hidden = true;
|
|
122
|
+
signedIn.hidden = false;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
/* ignore — fall back to the default signed-out placeholder */
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
</script>
|
|
128
|
+
|
|
33
129
|
<style is:global>
|
|
34
130
|
.user-menu-shell {
|
|
35
131
|
display: grid;
|
|
@@ -76,6 +172,14 @@ const GITHUB_ICON_PATH =
|
|
|
76
172
|
.um-fallback[hidden] {
|
|
77
173
|
display: none;
|
|
78
174
|
}
|
|
175
|
+
/* Variants flow their children directly into the .user-menu flex row
|
|
176
|
+
(so the parent's gap applies), and collapse fully when hidden. */
|
|
177
|
+
.um-fallback-variant {
|
|
178
|
+
display: contents;
|
|
179
|
+
}
|
|
180
|
+
.um-fallback-variant[hidden] {
|
|
181
|
+
display: none;
|
|
182
|
+
}
|
|
79
183
|
.um-btn {
|
|
80
184
|
display: inline-flex;
|
|
81
185
|
align-items: center;
|
|
@@ -27,15 +27,72 @@ interface Session {
|
|
|
27
27
|
user?: SessionUser;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Key for the client-side session snapshot. Shared with the inline
|
|
31
|
+
// pre-paint script in UserMenu.astro — keep both in sync.
|
|
32
|
+
const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
|
|
33
|
+
|
|
34
|
+
// Read the last-known session synchronously so the island can paint the
|
|
35
|
+
// correct menu on its very first render instead of waiting a network
|
|
36
|
+
// round-trip (the source of the nav "reload flash"). Returns:
|
|
37
|
+
// - `undefined` → never resolved on this device; render nothing yet
|
|
38
|
+
// - `null` → last known to be signed out
|
|
39
|
+
// - Session → last known signed-in user
|
|
40
|
+
function readAuthSnapshot(): Session | null | undefined {
|
|
41
|
+
if (typeof window === "undefined") return undefined;
|
|
42
|
+
try {
|
|
43
|
+
const raw = window.localStorage.getItem(AUTH_SNAPSHOT_KEY);
|
|
44
|
+
if (!raw) return undefined;
|
|
45
|
+
const parsed = JSON.parse(raw) as { user?: SessionUser | null };
|
|
46
|
+
return parsed?.user ? { user: parsed.user } : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Persist a minimal, non-sensitive snapshot (name/email/image) so the
|
|
53
|
+
// next page load can render optimistically. Stores `{ user: null }` when
|
|
54
|
+
// signed out so a returning signed-out user also skips the loading gap.
|
|
55
|
+
function writeAuthSnapshot(session: Session | null) {
|
|
56
|
+
if (typeof window === "undefined") return;
|
|
57
|
+
try {
|
|
58
|
+
const user = session?.user
|
|
59
|
+
? {
|
|
60
|
+
name: session.user.name ?? null,
|
|
61
|
+
email: session.user.email ?? null,
|
|
62
|
+
image: session.user.image ?? null,
|
|
63
|
+
}
|
|
64
|
+
: null;
|
|
65
|
+
window.localStorage.setItem(AUTH_SNAPSHOT_KEY, JSON.stringify({ user }));
|
|
66
|
+
} catch {
|
|
67
|
+
/* storage unavailable (private mode, quota) — degrade to no cache */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearAuthSnapshot() {
|
|
72
|
+
if (typeof window === "undefined") return;
|
|
73
|
+
try {
|
|
74
|
+
window.localStorage.removeItem(AUTH_SNAPSHOT_KEY);
|
|
75
|
+
} catch {
|
|
76
|
+
/* storage unavailable (private mode, quota) — degrade to no cache */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
30
80
|
const GITHUB_ICON_PATH =
|
|
31
81
|
"M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
|
|
32
82
|
|
|
33
83
|
export default function UserMenu() {
|
|
34
84
|
// `undefined` = not yet loaded; `null` = no auth or signed out;
|
|
35
|
-
// object = signed in.
|
|
85
|
+
// object = signed in. Seeded from the last-known snapshot so a
|
|
86
|
+
// returning user paints the right menu immediately, then revalidated
|
|
87
|
+
// by the fetch below. The tri-state avoids flashing the sign-in
|
|
36
88
|
// button while the session fetch is in flight.
|
|
37
|
-
const [session, setSession] = useState<Session | null | undefined>(
|
|
89
|
+
const [session, setSession] = useState<Session | null | undefined>(readAuthSnapshot);
|
|
38
90
|
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
|
91
|
+
// Flips true once the network fetch settles (success, 404, or error).
|
|
92
|
+
// Lets us tell a *cached* signed-out state (keep the static fallback
|
|
93
|
+
// visible until we know more) apart from a *resolved* not-wired state
|
|
94
|
+
// (hide the fallback so Tier-1 scaffolds show no dead UI).
|
|
95
|
+
const [resolved, setResolved] = useState(false);
|
|
39
96
|
|
|
40
97
|
useEffect(() => {
|
|
41
98
|
let cancelled = false;
|
|
@@ -50,16 +107,23 @@ export default function UserMenu() {
|
|
|
50
107
|
if (!sessRes.ok || !csrfRes.ok) {
|
|
51
108
|
setSession(null);
|
|
52
109
|
setCsrfToken(null);
|
|
110
|
+
setResolved(true);
|
|
111
|
+
clearAuthSnapshot();
|
|
53
112
|
return;
|
|
54
113
|
}
|
|
55
114
|
const sess = (await sessRes.json()) as Session | null;
|
|
56
115
|
const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
|
|
57
|
-
|
|
116
|
+
const nextSession = sess?.user ? sess : null;
|
|
117
|
+
setSession(nextSession);
|
|
58
118
|
setCsrfToken(csrf?.csrfToken ?? null);
|
|
119
|
+
setResolved(true);
|
|
120
|
+
writeAuthSnapshot(nextSession);
|
|
59
121
|
} catch {
|
|
60
122
|
if (!cancelled) {
|
|
61
123
|
setSession(null);
|
|
62
124
|
setCsrfToken(null);
|
|
125
|
+
setResolved(true);
|
|
126
|
+
clearAuthSnapshot();
|
|
63
127
|
}
|
|
64
128
|
}
|
|
65
129
|
})();
|
|
@@ -69,16 +133,28 @@ export default function UserMenu() {
|
|
|
69
133
|
}, []);
|
|
70
134
|
|
|
71
135
|
useEffect(() => {
|
|
72
|
-
|
|
136
|
+
// Only retire the static fallback once the island has something to
|
|
137
|
+
// show in its place (a known user or the wired sign-in form) or the
|
|
138
|
+
// fetch has resolved with nothing to show (Tier-1). Until then, keep
|
|
139
|
+
// the server-rendered fallback so the nav never goes blank.
|
|
140
|
+
const islandHasContent = Boolean(session?.user) || Boolean(csrfToken);
|
|
141
|
+
if (!islandHasContent && !resolved) return;
|
|
73
142
|
document.querySelectorAll<HTMLElement>("[data-user-menu-fallback]").forEach((el) => {
|
|
74
143
|
el.hidden = true;
|
|
75
144
|
});
|
|
76
|
-
}, [session]);
|
|
77
|
-
|
|
78
|
-
// Loading or auth not wired → render nothing.
|
|
79
|
-
if (session === undefined || !csrfToken) return null;
|
|
145
|
+
}, [session, csrfToken, resolved]);
|
|
80
146
|
|
|
81
147
|
const user = session?.user;
|
|
148
|
+
|
|
149
|
+
// Render nothing until we either know a signed-in user (from the cache
|
|
150
|
+
// or the fetch) or have confirmed auth is wired (csrf token present).
|
|
151
|
+
// A cached user paints immediately — that's what kills the reload
|
|
152
|
+
// flash. Withholding the signed-out form until csrf loads keeps
|
|
153
|
+
// Tier-1 scaffolds — where the auth endpoints 404, so csrf stays
|
|
154
|
+
// null — from surfacing a dead "Sign in with GitHub" button, and
|
|
155
|
+
// avoids a tokenless form before the round-trip completes.
|
|
156
|
+
if (!user && !csrfToken) return null;
|
|
157
|
+
|
|
82
158
|
const callbackUrl = typeof window !== "undefined" ? window.location.href : withBase("/");
|
|
83
159
|
|
|
84
160
|
// Compact label for the topbar: first word of `name`, falling back
|
|
@@ -127,10 +203,19 @@ export default function UserMenu() {
|
|
|
127
203
|
</svg>
|
|
128
204
|
<span>MCP setup</span>
|
|
129
205
|
</a>
|
|
130
|
-
<form
|
|
131
|
-
|
|
206
|
+
<form
|
|
207
|
+
method="post"
|
|
208
|
+
action={withBase("/api/auth/signout")}
|
|
209
|
+
onSubmit={() => writeAuthSnapshot(null)}
|
|
210
|
+
>
|
|
211
|
+
<input type="hidden" name="csrfToken" value={csrfToken ?? ""} />
|
|
132
212
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
133
|
-
<button
|
|
213
|
+
<button
|
|
214
|
+
type="submit"
|
|
215
|
+
className="um-btn um-btn-icon"
|
|
216
|
+
title="Sign out"
|
|
217
|
+
disabled={!csrfToken}
|
|
218
|
+
>
|
|
134
219
|
<svg
|
|
135
220
|
viewBox="0 0 24 24"
|
|
136
221
|
width="14"
|
|
@@ -152,9 +237,9 @@ export default function UserMenu() {
|
|
|
152
237
|
</>
|
|
153
238
|
) : (
|
|
154
239
|
<form method="post" action={withBase("/api/auth/signin/github")}>
|
|
155
|
-
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
240
|
+
<input type="hidden" name="csrfToken" value={csrfToken ?? ""} />
|
|
156
241
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
157
|
-
<button type="submit" className="um-btn">
|
|
242
|
+
<button type="submit" className="um-btn" disabled={!csrfToken}>
|
|
158
243
|
<svg
|
|
159
244
|
className="um-gh"
|
|
160
245
|
viewBox="0 0 24 24"
|