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.
Files changed (89) hide show
  1. package/package.json +74 -0
  2. package/src/collections.ts +150 -0
  3. package/src/components/Footer.astro +85 -0
  4. package/src/components/Navbar.astro +74 -0
  5. package/src/components/Progress.tsx +36 -0
  6. package/src/components/Sidebar.astro +162 -0
  7. package/src/components/StepNav.astro +107 -0
  8. package/src/components/ai/ByokSetup.tsx +90 -0
  9. package/src/components/ai/ChatButton.tsx +30 -0
  10. package/src/components/ai/ChatPanel.tsx +244 -0
  11. package/src/components/auth/SignInButton.astro +41 -0
  12. package/src/components/auth/UserMenu.astro +79 -0
  13. package/src/components/auth/UserMenu.tsx +136 -0
  14. package/src/components/home/FilterBar.tsx +152 -0
  15. package/src/components/home/Hero.astro +60 -0
  16. package/src/components/home/Pagination.tsx +89 -0
  17. package/src/components/home/ResumeRail.tsx +50 -0
  18. package/src/components/home/TutorialCard.astro +185 -0
  19. package/src/components/mdx/Callout.astro +77 -0
  20. package/src/components/mdx/Checkpoint.astro +14 -0
  21. package/src/components/mdx/Checkpoint.tsx +49 -0
  22. package/src/components/mdx/Diff.astro +6 -0
  23. package/src/components/mdx/Diff.tsx +100 -0
  24. package/src/components/mdx/Download.astro +37 -0
  25. package/src/components/mdx/Embed.astro +56 -0
  26. package/src/components/mdx/File.astro +28 -0
  27. package/src/components/mdx/FileTree.astro +6 -0
  28. package/src/components/mdx/FileTree.tsx +71 -0
  29. package/src/components/mdx/Hint.astro +51 -0
  30. package/src/components/mdx/Mermaid.astro +6 -0
  31. package/src/components/mdx/Mermaid.tsx +47 -0
  32. package/src/components/mdx/Playground.astro +6 -0
  33. package/src/components/mdx/Playground.tsx +34 -0
  34. package/src/components/mdx/Quiz.astro +6 -0
  35. package/src/components/mdx/Quiz.tsx +102 -0
  36. package/src/components/mdx/Recap.astro +65 -0
  37. package/src/components/mdx/Reveal.astro +7 -0
  38. package/src/components/mdx/Reveal.tsx +25 -0
  39. package/src/components/mdx/Step.astro +12 -0
  40. package/src/components/mdx/Steps.astro +40 -0
  41. package/src/components/mdx/Tab.astro +22 -0
  42. package/src/components/mdx/Tabs.astro +67 -0
  43. package/src/components/mdx/Terminal.astro +6 -0
  44. package/src/components/mdx/Terminal.tsx +47 -0
  45. package/src/index.ts +55 -0
  46. package/src/layouts/BaseLayout.astro +112 -0
  47. package/src/layouts/TutorialLayout.astro +218 -0
  48. package/src/lib/ai/client.ts +92 -0
  49. package/src/lib/ai/context.ts +97 -0
  50. package/src/lib/content.ts +73 -0
  51. package/src/lib/mdx-components.ts +47 -0
  52. package/src/lib/progress/local.ts +89 -0
  53. package/src/lib/progress/remote.ts +199 -0
  54. package/src/lib/progress/types.ts +63 -0
  55. package/src/lib/progress/useProgress.ts +117 -0
  56. package/src/lib/rehype-mermaid-passthrough.ts +31 -0
  57. package/src/pages/Home.astro +408 -0
  58. package/src/pages/TutorialLanding.astro +324 -0
  59. package/src/pages/TutorialStep.astro +67 -0
  60. package/src/pages/paths.ts +36 -0
  61. package/src/server/auth/config.ts +102 -0
  62. package/src/server/auth/schema.ts +66 -0
  63. package/src/server/auth/session.ts +27 -0
  64. package/src/server/auth.ts +127 -0
  65. package/src/server/db/client.ts +14 -0
  66. package/src/server/db/migrate.ts +29 -0
  67. package/src/server/db/schema.ts +65 -0
  68. package/src/server/handlers/healthz.ts +6 -0
  69. package/src/server/handlers/progress.ts +90 -0
  70. package/src/server/handlers/tutorialStats.ts +67 -0
  71. package/src/server/http.ts +33 -0
  72. package/src/types/ai.ts +17 -0
  73. package/styles/base.css +127 -0
  74. package/styles/components/a11y.css +12 -0
  75. package/styles/components/byok.css +50 -0
  76. package/styles/components/chat.css +304 -0
  77. package/styles/components/checkpoint.css +49 -0
  78. package/styles/components/diff.css +44 -0
  79. package/styles/components/expressive-code.css +61 -0
  80. package/styles/components/filetree.css +68 -0
  81. package/styles/components/mermaid.css +19 -0
  82. package/styles/components/modal.css +25 -0
  83. package/styles/components/progress.css +19 -0
  84. package/styles/components/quiz.css +101 -0
  85. package/styles/components/reveal.css +25 -0
  86. package/styles/components/tabs.css +60 -0
  87. package/styles/components/terminal.css +55 -0
  88. package/styles/components.css +28 -0
  89. package/styles/global.css +15 -0
@@ -0,0 +1,90 @@
1
+ import * as Dialog from "@radix-ui/react-dialog";
2
+ import { X } from "lucide-react";
3
+ import { useState } from "react";
4
+ import { saveLearnerKey } from "../../lib/ai/client";
5
+
6
+ interface Props {
7
+ open: boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ provider: string;
10
+ assistantName: string;
11
+ onKeySaved?: () => void;
12
+ }
13
+
14
+ const PROVIDER_INFO: Record<string, { label: string; href: string }> = {
15
+ anthropic: { label: "Anthropic (Claude)", href: "https://console.anthropic.com/settings/keys" },
16
+ openai: { label: "OpenAI", href: "https://platform.openai.com/api-keys" },
17
+ google: { label: "Google AI Studio", href: "https://aistudio.google.com/app/apikey" },
18
+ "openai-compatible": { label: "OpenAI-compatible", href: "" },
19
+ };
20
+
21
+ export default function ByokSetup({
22
+ open,
23
+ onOpenChange,
24
+ provider,
25
+ assistantName,
26
+ onKeySaved,
27
+ }: Props) {
28
+ const [key, setKey] = useState("");
29
+ const info = PROVIDER_INFO[provider] ?? { label: provider, href: "" };
30
+
31
+ function save() {
32
+ if (!key.trim()) return;
33
+ saveLearnerKey(provider, key.trim());
34
+ setKey("");
35
+ onOpenChange(false);
36
+ onKeySaved?.();
37
+ }
38
+
39
+ return (
40
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
41
+ <Dialog.Portal>
42
+ <Dialog.Overlay className="ms-overlay" />
43
+ <Dialog.Content className="byok-panel">
44
+ <Dialog.Close asChild>
45
+ <button type="button" className="ms-close" aria-label="Close">
46
+ <X size={18} />
47
+ </button>
48
+ </Dialog.Close>
49
+ <Dialog.Title>Set up {assistantName}</Dialog.Title>
50
+ <Dialog.Description className="byok-desc">
51
+ {assistantName} needs an API key to answer questions. The key is stored in this browser,
52
+ and sent over TLS to the assistant service with each question so it can call your chosen
53
+ model provider on your behalf. It is never written to a database or shared with other
54
+ learners.
55
+ </Dialog.Description>
56
+
57
+ {info.href && (
58
+ <p className="byok-link">
59
+ Get a {info.label} key →{" "}
60
+ <a href={info.href} target="_blank" rel="noopener noreferrer">
61
+ {info.href.replace(/^https?:\/\//, "")}
62
+ </a>
63
+ </p>
64
+ )}
65
+
66
+ <label className="byok-field">
67
+ <span>API key</span>
68
+ <input
69
+ type="password"
70
+ value={key}
71
+ onChange={(e) => setKey(e.target.value)}
72
+ placeholder="sk-..."
73
+ />
74
+ </label>
75
+
76
+ <div className="byok-actions">
77
+ <button type="button" onClick={save} disabled={!key.trim()}>
78
+ Save key
79
+ </button>
80
+ </div>
81
+
82
+ <p className="byok-disclaimer">
83
+ The assistant service forwards your key to {info.label} on each request and discards it
84
+ after the response. It is never logged or persisted server-side.
85
+ </p>
86
+ </Dialog.Content>
87
+ </Dialog.Portal>
88
+ </Dialog.Root>
89
+ );
90
+ }
@@ -0,0 +1,30 @@
1
+ import { Sparkles } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { AiConfig } from "../../types/ai";
4
+ import type { AssistantContext } from "../../lib/ai/context";
5
+ import ChatPanel from "./ChatPanel";
6
+
7
+ interface Props {
8
+ config: AiConfig;
9
+ context: AssistantContext;
10
+ }
11
+
12
+ export default function ChatButton({ config, context }: Props) {
13
+ const [open, setOpen] = useState(false);
14
+ if (!config.enabled) return null;
15
+
16
+ return (
17
+ <>
18
+ <button
19
+ type="button"
20
+ className="chat-fab"
21
+ onClick={() => setOpen(true)}
22
+ aria-label={`Open ${config.name}`}
23
+ >
24
+ <Sparkles size={18} aria-hidden="true" />
25
+ <span>{config.name}</span>
26
+ </button>
27
+ <ChatPanel open={open} onOpenChange={setOpen} config={config} context={context} />
28
+ </>
29
+ );
30
+ }
@@ -0,0 +1,244 @@
1
+ import * as Dialog from "@radix-ui/react-dialog";
2
+ import { KeyRound, Send, Settings, Sparkles, Trash2, X } from "lucide-react";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import type { AiConfig } from "../../types/ai";
7
+ import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
8
+ import type { AssistantContext } from "../../lib/ai/context";
9
+ import ByokSetup from "./ByokSetup";
10
+
11
+ interface Props {
12
+ open: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ config: AiConfig;
15
+ context: AssistantContext;
16
+ }
17
+
18
+ export default function ChatPanel({ open, onOpenChange, config, context }: Props) {
19
+ const [messages, setMessages] = useState<ChatMessage[]>(() => {
20
+ if (config.greeting) {
21
+ return [{ role: "assistant", content: config.greeting }];
22
+ }
23
+ return [
24
+ {
25
+ role: "assistant",
26
+ content: `Hi, I'm ${config.name}. Ask me anything about "${context.tutorial.title}". I can see what step you're on.`,
27
+ },
28
+ ];
29
+ });
30
+ const [input, setInput] = useState("");
31
+ const [streaming, setStreaming] = useState(false);
32
+ const [byokOpen, setByokOpen] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ // Reactive copy of the stored BYOK key so the chat can show / hide its
35
+ // "set up your key" card without waiting for the learner to try to
36
+ // send a message. localStorage isn't readable on the server, hence
37
+ // the mount-time read instead of useState initialiser.
38
+ const [learnerKey, setLearnerKey] = useState<string | null>(null);
39
+ useEffect(() => {
40
+ setLearnerKey(loadLearnerKey(config.provider));
41
+ }, [config.provider]);
42
+ const needsKey = config.byok === "required" && !learnerKey;
43
+ const abortRef = useRef<AbortController | null>(null);
44
+ const listRef = useRef<HTMLDivElement>(null);
45
+
46
+ // Keep the latest message in view as chunks stream in (and on every
47
+ // send / clear). Without this, long responses scroll out of frame.
48
+ useEffect(() => {
49
+ listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
50
+ }, [messages, streaming]);
51
+
52
+ async function send() {
53
+ const trimmed = input.trim();
54
+ if (!trimmed || streaming) return;
55
+
56
+ if (needsKey) {
57
+ setByokOpen(true);
58
+ return;
59
+ }
60
+
61
+ const next: ChatMessage[] = [...messages, { role: "user", content: trimmed }];
62
+ setMessages(next);
63
+ setInput("");
64
+ setStreaming(true);
65
+ setError(null);
66
+
67
+ abortRef.current = new AbortController();
68
+ try {
69
+ const stream = await streamChat({
70
+ messages: next,
71
+ config,
72
+ context,
73
+ learnerKey: learnerKey ?? undefined,
74
+ signal: abortRef.current.signal,
75
+ });
76
+ // Delay adding the assistant message until the first chunk
77
+ // arrives so the thinking indicator (shown when the last
78
+ // message is from the user) stays visible during the wait.
79
+ let acc = "";
80
+ let assistantAdded = false;
81
+ const reader = stream.getReader();
82
+ while (true) {
83
+ const { value, done } = await reader.read();
84
+ if (done) break;
85
+ acc += value;
86
+ if (!assistantAdded) {
87
+ setMessages((m) => [...m, { role: "assistant", content: acc }]);
88
+ assistantAdded = true;
89
+ } else {
90
+ setMessages((m) => {
91
+ const copy = m.slice();
92
+ copy[copy.length - 1] = { role: "assistant", content: acc };
93
+ return copy;
94
+ });
95
+ }
96
+ }
97
+ } catch (e) {
98
+ const msg = e instanceof Error ? e.message : String(e);
99
+ setError(msg);
100
+ } finally {
101
+ setStreaming(false);
102
+ }
103
+ }
104
+
105
+ function clear() {
106
+ setMessages([]);
107
+ setError(null);
108
+ }
109
+
110
+ function resetKey() {
111
+ clearLearnerKey(config.provider);
112
+ setLearnerKey(null);
113
+ setByokOpen(true);
114
+ }
115
+
116
+ return (
117
+ <>
118
+ {/*
119
+ modal={false}: the chat is a co-pilot, not a takeover. Removing
120
+ the overlay + focus trap lets the learner keep scrolling the
121
+ tutorial, clicking code blocks, and editing the playground
122
+ while the assistant is open. ESC still closes via Radix.
123
+ */}
124
+ <Dialog.Root open={open} onOpenChange={onOpenChange} modal={false}>
125
+ <Dialog.Portal>
126
+ <Dialog.Content
127
+ className="chat-panel"
128
+ aria-describedby={undefined}
129
+ onInteractOutside={(e) => e.preventDefault()}
130
+ >
131
+ <header className="chat-head">
132
+ <div className="chat-head-id">
133
+ <Sparkles size={16} className="chat-head-icon" aria-hidden="true" />
134
+ <div>
135
+ <Dialog.Title className="chat-title">{config.name}</Dialog.Title>
136
+ {config.tagline && <div className="chat-tagline">{config.tagline}</div>}
137
+ </div>
138
+ </div>
139
+ <div className="chat-head-actions">
140
+ {config.byok !== "disabled" && (
141
+ <button type="button" title="Reset key" onClick={resetKey}>
142
+ <Settings size={16} />
143
+ </button>
144
+ )}
145
+ <button type="button" title="Clear chat" onClick={clear}>
146
+ <Trash2 size={16} />
147
+ </button>
148
+ <Dialog.Close asChild>
149
+ <button type="button" aria-label="Close">
150
+ <X size={16} />
151
+ </button>
152
+ </Dialog.Close>
153
+ </div>
154
+ </header>
155
+
156
+ <div className="chat-meta">
157
+ On: <strong>{context.currentStep.title}</strong>
158
+ </div>
159
+
160
+ {needsKey ? (
161
+ <div className="chat-setup" role="status">
162
+ <KeyRound size={22} aria-hidden="true" />
163
+ <h3>API key required</h3>
164
+ <p>
165
+ {config.name} needs an API key to answer questions. Add one to get started —
166
+ it's stored in this browser only.
167
+ </p>
168
+ <button type="button" onClick={() => setByokOpen(true)}>
169
+ Set up key
170
+ </button>
171
+ </div>
172
+ ) : (
173
+ <div className="chat-list" ref={listRef}>
174
+ {messages.map((m, i) => (
175
+ <div key={i} className={`chat-msg chat-msg-${m.role}`}>
176
+ <span className="chat-role">{m.role === "user" ? "You" : config.name}</span>
177
+ <div className="chat-content">
178
+ {m.role === "assistant" ? (
179
+ // react-markdown sanitizes by default (no raw HTML),
180
+ // which matters because the assistant output is
181
+ // untrusted. remark-gfm adds tables + strikethrough.
182
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
183
+ ) : (
184
+ m.content
185
+ )}
186
+ </div>
187
+ </div>
188
+ ))}
189
+ {/*
190
+ Show the thinking indicator while we're streaming AND the
191
+ last message isn't yet from the assistant (i.e. we haven't
192
+ received the first chunk that adds an empty assistant
193
+ message to the array). Once chunks start landing, the
194
+ message itself updates and the dots disappear.
195
+ */}
196
+ {streaming && messages[messages.length - 1]?.role !== "assistant" && (
197
+ <div className="chat-msg chat-msg-assistant">
198
+ <span className="chat-role">{config.name}</span>
199
+ <div className="chat-content">
200
+ <span className="chat-thinking" aria-label="Thinking">
201
+ <span /> <span /> <span />
202
+ </span>
203
+ </div>
204
+ </div>
205
+ )}
206
+ {error && <div className="chat-error">⚠ {error}</div>}
207
+ </div>
208
+ )}
209
+
210
+ <form
211
+ className="chat-input"
212
+ onSubmit={(e) => {
213
+ e.preventDefault();
214
+ void send();
215
+ }}
216
+ >
217
+ <input
218
+ value={input}
219
+ onChange={(e) => setInput(e.target.value)}
220
+ placeholder={needsKey ? "Add an API key to start chatting" : `Ask ${config.name}…`}
221
+ disabled={streaming || needsKey}
222
+ />
223
+ <button
224
+ type="submit"
225
+ disabled={streaming || needsKey || !input.trim()}
226
+ aria-label="Send"
227
+ >
228
+ <Send size={16} />
229
+ </button>
230
+ </form>
231
+ </Dialog.Content>
232
+ </Dialog.Portal>
233
+ </Dialog.Root>
234
+
235
+ <ByokSetup
236
+ open={byokOpen}
237
+ onOpenChange={setByokOpen}
238
+ provider={config.provider}
239
+ assistantName={config.name}
240
+ onKeySaved={() => setLearnerKey(loadLearnerKey(config.provider))}
241
+ />
242
+ </>
243
+ );
244
+ }
@@ -0,0 +1,41 @@
1
+ ---
2
+ /**
3
+ * Themed re-export of auth-astro's `SignIn` component. Use this when
4
+ * the bare SignIn needs styling consistent with the rest of the brutalist
5
+ * tokens — `UserMenu` already wraps this for you.
6
+ *
7
+ * <SignInButton provider="github">Sign in with GitHub</SignInButton>
8
+ */
9
+ import { SignIn } from "auth-astro/components";
10
+
11
+ interface Props {
12
+ provider?: string;
13
+ className?: string;
14
+ }
15
+
16
+ const { provider = "github", className } = Astro.props;
17
+ ---
18
+ <SignIn provider={provider} class:list={["sib", className]}>
19
+ <slot>Sign in</slot>
20
+ </SignIn>
21
+
22
+ <style is:global>
23
+ .sib {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: 0.4rem;
27
+ padding: 0.45rem 0.85rem;
28
+ background: transparent;
29
+ color: var(--color-fg);
30
+ border: var(--border-default, 2px) solid var(--color-fg);
31
+ font-family: var(--font-mono);
32
+ font-size: 0.85em;
33
+ text-decoration: none;
34
+ cursor: pointer;
35
+ }
36
+ .sib:hover {
37
+ background: var(--color-accent);
38
+ color: var(--color-accent-fg);
39
+ border-color: var(--color-accent);
40
+ }
41
+ </style>
@@ -0,0 +1,79 @@
1
+ ---
2
+ /**
3
+ * Astro wrapper around the React UserMenu island. Auth state is
4
+ * fetched on the client (see UserMenu.tsx), so this component works
5
+ * the same on prerendered pages and SSR routes — no
6
+ * `Astro.request.headers` access at render time, no warning when a
7
+ * prerendered route happens to include BaseLayout.
8
+ */
9
+ import UserMenuIsland from "./UserMenu.tsx";
10
+ ---
11
+ <UserMenuIsland client:only="react" />
12
+
13
+ <style is:global>
14
+ .user-menu {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: 0.5rem;
18
+ font-family: var(--font-mono);
19
+ font-size: 0.8em;
20
+ }
21
+ .um-avatar {
22
+ width: 24px;
23
+ height: 24px;
24
+ border-radius: 50%;
25
+ border: 1px solid var(--color-border);
26
+ display: inline-block;
27
+ object-fit: cover;
28
+ flex-shrink: 0;
29
+ }
30
+ .um-avatar-fallback {
31
+ background: color-mix(in oklab, var(--color-accent) 15%, var(--color-bg));
32
+ color: var(--color-accent);
33
+ font-weight: 700;
34
+ font-size: 0.75em;
35
+ display: inline-grid;
36
+ place-items: center;
37
+ }
38
+ .um-name {
39
+ color: var(--color-fg);
40
+ max-width: 12ch;
41
+ overflow: hidden;
42
+ text-overflow: ellipsis;
43
+ white-space: nowrap;
44
+ }
45
+ .user-menu form { display: inline-flex; }
46
+ .um-btn {
47
+ display: inline-flex;
48
+ align-items: center;
49
+ gap: 0.35rem;
50
+ padding: 0.3rem 0.65rem;
51
+ background: transparent;
52
+ color: var(--color-fg);
53
+ border: 1px solid var(--color-border);
54
+ font: inherit;
55
+ text-decoration: none;
56
+ cursor: pointer;
57
+ }
58
+ .um-btn:hover {
59
+ border-color: var(--color-accent);
60
+ color: var(--color-accent);
61
+ }
62
+ .um-btn-icon {
63
+ padding: 0.3rem 0.45rem;
64
+ }
65
+ .um-gh {
66
+ flex-shrink: 0;
67
+ }
68
+ .sr-only {
69
+ position: absolute;
70
+ width: 1px;
71
+ height: 1px;
72
+ padding: 0;
73
+ margin: -1px;
74
+ overflow: hidden;
75
+ clip: rect(0, 0, 0, 0);
76
+ white-space: nowrap;
77
+ border: 0;
78
+ }
79
+ </style>
@@ -0,0 +1,136 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /**
4
+ * Client-only auth menu. Fetches `/api/auth/session` + `/api/auth/csrf`
5
+ * on mount and renders either:
6
+ * - signed-out: a "Sign in with GitHub" form-post button
7
+ * - signed-in: avatar + name + sign-out icon button
8
+ * - nothing: if the auth-astro integration isn't wired (the
9
+ * endpoints 404), so dropping handzon-core into a Tier-1
10
+ * scaffold doesn't surface dead UI
11
+ *
12
+ * Lives as a `client:only="react"` island instead of an Astro component
13
+ * so it works on prerendered pages too — Astro warns whenever a
14
+ * prerendered route accesses `Astro.request.headers`, which the old
15
+ * server-rendered UserMenu did transitively via auth-astro's
16
+ * `getSession`. With the data fetched on the client there's no SSR
17
+ * dependency on request state at all.
18
+ */
19
+
20
+ interface SessionUser {
21
+ name?: string | null;
22
+ email?: string | null;
23
+ image?: string | null;
24
+ }
25
+ interface Session {
26
+ user?: SessionUser;
27
+ }
28
+
29
+ const GITHUB_ICON_PATH =
30
+ "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";
31
+
32
+ export default function UserMenu() {
33
+ // `undefined` = not yet loaded; `null` = no auth or signed out;
34
+ // object = signed in. The tri-state avoids flashing the sign-in
35
+ // button while the session fetch is in flight.
36
+ const [session, setSession] = useState<Session | null | undefined>(undefined);
37
+ const [csrfToken, setCsrfToken] = useState<string | null>(null);
38
+
39
+ useEffect(() => {
40
+ let cancelled = false;
41
+ (async () => {
42
+ try {
43
+ const [sessRes, csrfRes] = await Promise.all([
44
+ fetch("/api/auth/session", { credentials: "same-origin" }),
45
+ fetch("/api/auth/csrf", { credentials: "same-origin" }),
46
+ ]);
47
+ if (cancelled) return;
48
+ // 404 → auth-astro integration not wired in this scaffold.
49
+ if (!sessRes.ok || !csrfRes.ok) {
50
+ setSession(null);
51
+ setCsrfToken(null);
52
+ return;
53
+ }
54
+ const sess = (await sessRes.json()) as Session | null;
55
+ const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
56
+ setSession(sess && sess.user ? sess : null);
57
+ setCsrfToken(csrf?.csrfToken ?? null);
58
+ } catch {
59
+ if (!cancelled) {
60
+ setSession(null);
61
+ setCsrfToken(null);
62
+ }
63
+ }
64
+ })();
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, []);
69
+
70
+ // Loading or auth not wired → render nothing.
71
+ if (session === undefined || !csrfToken) return null;
72
+
73
+ const user = session?.user;
74
+ const callbackUrl = typeof window !== "undefined" ? window.location.href : "/";
75
+
76
+ return (
77
+ <div className="user-menu">
78
+ {user ? (
79
+ <>
80
+ {user.image ? (
81
+ <img
82
+ className="um-avatar"
83
+ src={user.image}
84
+ alt={user.name ?? user.email ?? "Signed in"}
85
+ />
86
+ ) : (
87
+ <span className="um-avatar um-avatar-fallback" aria-hidden="true">
88
+ {(user.name ?? user.email ?? "?").trim().charAt(0).toUpperCase()}
89
+ </span>
90
+ )}
91
+ <span className="um-name">{user.name ?? user.email ?? "Signed in"}</span>
92
+ <form method="post" action="/api/auth/signout">
93
+ <input type="hidden" name="csrfToken" value={csrfToken} />
94
+ <input type="hidden" name="callbackUrl" value={callbackUrl} />
95
+ <button type="submit" className="um-btn um-btn-icon" title="Sign out">
96
+ <svg
97
+ viewBox="0 0 24 24"
98
+ width="14"
99
+ height="14"
100
+ fill="none"
101
+ stroke="currentColor"
102
+ strokeWidth="2"
103
+ strokeLinecap="round"
104
+ strokeLinejoin="round"
105
+ aria-hidden="true"
106
+ >
107
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
108
+ <polyline points="16 17 21 12 16 7" />
109
+ <line x1="21" y1="12" x2="9" y2="12" />
110
+ </svg>
111
+ <span className="sr-only">Sign out</span>
112
+ </button>
113
+ </form>
114
+ </>
115
+ ) : (
116
+ <form method="post" action="/api/auth/signin/github">
117
+ <input type="hidden" name="csrfToken" value={csrfToken} />
118
+ <input type="hidden" name="callbackUrl" value={callbackUrl} />
119
+ <button type="submit" className="um-btn">
120
+ <svg
121
+ className="um-gh"
122
+ viewBox="0 0 24 24"
123
+ width="14"
124
+ height="14"
125
+ fill="currentColor"
126
+ aria-hidden="true"
127
+ >
128
+ <path d={GITHUB_ICON_PATH} />
129
+ </svg>
130
+ <span>Sign in with GitHub</span>
131
+ </button>
132
+ </form>
133
+ )}
134
+ </div>
135
+ );
136
+ }