diff --git a/app/lessons/[slug]/page.tsx b/app/lessons/[slug]/page.tsx index 4ea5dc8..92df4a6 100644 --- a/app/lessons/[slug]/page.tsx +++ b/app/lessons/[slug]/page.tsx @@ -1,13 +1,14 @@ import { Suspense } from "react"; import Link from "next/link"; import { headers } from "next/headers"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { MDXRemote } from "next-mdx-remote/rsc"; import { auth } from "@/lib/auth"; import { getAllLessons, getLesson } from "@/lib/lessons"; import { buildLessonComponents } from "@/components/lesson/mdx-components"; import { SandboxSection } from "@/components/lesson/SandboxSection"; import { SandboxLoading } from "@/components/lesson/SandboxLoading"; +import { RestoreScroll } from "@/components/lesson/RestoreScroll"; import { getPassedCheckIds } from "@/lib/lesson-progress"; type Params = { slug: string }; @@ -32,13 +33,15 @@ export default async function LessonPage({ const { slug } = await params; const session = await auth.api.getSession({ headers: await headers() }); - if (!session) redirect("/"); + const isSignedIn = !!session; const lesson = await getLesson(slug); if (!lesson) notFound(); - const passedCheckIds = await getPassedCheckIds(session.user.id, slug); - const components = buildLessonComponents({ lesson, passedCheckIds }); + const passedCheckIds = session + ? await getPassedCheckIds(session.user.id, slug) + : new Set(); + const components = buildLessonComponents({ lesson, passedCheckIds, isSignedIn }); const totalChecks = lesson.meta.checks.length; const passedCount = passedCheckIds.size; @@ -49,6 +52,7 @@ export default async function LessonPage({ return (
+
← All lessons @@ -123,7 +127,7 @@ export default async function LessonPage({
}> - +
diff --git a/app/sign-in-button.tsx b/app/sign-in-button.tsx index bae08ef..41dd810 100644 --- a/app/sign-in-button.tsx +++ b/app/sign-in-button.tsx @@ -1,31 +1,57 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { signIn } from "@/lib/auth-client"; -export function SignInButton() { +type Props = { + callbackURL?: string; + variant?: "default" | "inline"; + children?: ReactNode; + // Stash the current scroll offset so the destination page can restore it + // after the OAuth round-trip lands the user back where they were reading. + preserveScroll?: boolean; +}; + +export function SignInButton({ + callbackURL = "/dashboard", + variant = "default", + children, + preserveScroll = false, +}: Props) { const [loading, setLoading] = useState(false); const onClick = async () => { setLoading(true); - await signIn.social({ provider: "github", callbackURL: "/dashboard" }); + if (preserveScroll) { + sessionStorage.setItem( + `learn:scroll-restore:${callbackURL}`, + String(window.scrollY), + ); + } + await signIn.social({ provider: "github", callbackURL }); }; + const isInline = variant === "inline"; + return ( ); } diff --git a/components/lesson/Check.tsx b/components/lesson/Check.tsx index 2f8d739..91d4331 100644 --- a/components/lesson/Check.tsx +++ b/components/lesson/Check.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, type ReactNode } from "react"; +import { SignInButton } from "@/app/sign-in-button"; import type { Check } from "@/lib/lesson-schema"; type Status = "idle" | "running" | "pass" | "fail"; @@ -9,6 +10,8 @@ type Props = { check: Check | undefined; lessonSlug: string; initiallyPassed: boolean; + isSignedIn: boolean; + callbackURL: string; children?: ReactNode; }; @@ -16,6 +19,8 @@ export function CheckCard({ check, lessonSlug, initiallyPassed, + isSignedIn, + callbackURL, children, }: Props) { const [status, setStatus] = useState( @@ -86,18 +91,24 @@ export function CheckCard({ {check && (
- + {isSignedIn ? ( + + ) : ( + + Sign in to check + + )}
)} diff --git a/components/lesson/RestoreScroll.tsx b/components/lesson/RestoreScroll.tsx new file mode 100644 index 0000000..9060a50 --- /dev/null +++ b/components/lesson/RestoreScroll.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; + +// Restores the scroll offset stashed by a sign-in CTA before the OAuth +// round-trip, so the reader lands back where they were on the lesson. +export function RestoreScroll() { + useEffect(() => { + const key = `learn:scroll-restore:${window.location.pathname}`; + const saved = sessionStorage.getItem(key); + if (saved === null) return; + sessionStorage.removeItem(key); + + const y = Number.parseInt(saved, 10); + if (Number.isNaN(y)) return; + + // Wait for the SSR'd prose to lay out before jumping. + requestAnimationFrame(() => window.scrollTo(0, y)); + }, []); + + return null; +} diff --git a/components/lesson/RunBlock.tsx b/components/lesson/RunBlock.tsx index b3233cc..ebd783b 100644 --- a/components/lesson/RunBlock.tsx +++ b/components/lesson/RunBlock.tsx @@ -1,8 +1,15 @@ "use client"; import { useRef, type ReactNode } from "react"; +import { SignInButton } from "@/app/sign-in-button"; -export function RunBlock({ children }: { children: ReactNode }) { +type Props = { + children: ReactNode; + isSignedIn: boolean; + callbackURL: string; +}; + +export function RunBlock({ children, isSignedIn, callbackURL }: Props) { const preRef = useRef(null); const onRun = () => { @@ -17,14 +24,20 @@ export function RunBlock({ children }: { children: ReactNode }) {
sql - + {isSignedIn ? ( + + ) : ( + + Sign in to run + + )}
+    );
+  }
+
   if (
     !process.env.XATA_API_KEY ||
     !process.env.XATA_ORG_ID ||
diff --git a/components/lesson/SandboxSignInPrompt.tsx b/components/lesson/SandboxSignInPrompt.tsx
new file mode 100644
index 0000000..615e2f0
--- /dev/null
+++ b/components/lesson/SandboxSignInPrompt.tsx
@@ -0,0 +1,32 @@
+import { SignInButton } from "@/app/sign-in-button";
+
+export function SandboxSignInPrompt({ callbackURL }: { callbackURL: string }) {
+  return (
+    <>
+      
+ + + + sandbox locked + + +
+
+
+
+

+ Sign in to spin up your own Postgres sandbox and run the queries + for this lesson. +

+ + Sign in to start the sandbox + +
+
+
+ + ); +} diff --git a/components/lesson/mdx-components.tsx b/components/lesson/mdx-components.tsx index f58315f..da49e57 100644 --- a/components/lesson/mdx-components.tsx +++ b/components/lesson/mdx-components.tsx @@ -6,9 +6,15 @@ import { RunBlock } from "./RunBlock"; type BuildOpts = { lesson: Lesson; passedCheckIds: Set; + isSignedIn: boolean; }; -export function buildLessonComponents({ lesson, passedCheckIds }: BuildOpts) { +export function buildLessonComponents({ + lesson, + passedCheckIds, + isSignedIn, +}: BuildOpts) { + const callbackURL = `/lessons/${lesson.meta.slug}`; return { Check: ({ id, children }: { id: string; children?: ReactNode }) => { const check = lesson.meta.checks.find((c) => c.id === id); @@ -17,13 +23,17 @@ export function buildLessonComponents({ lesson, passedCheckIds }: BuildOpts) { check={check} lessonSlug={lesson.meta.slug} initiallyPassed={passedCheckIds.has(id)} + isSignedIn={isSignedIn} + callbackURL={callbackURL} > {children} ); }, Run: ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ), pre: (props: ComponentProps<"pre">) => (