Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions app/lessons/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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<string>();
const components = buildLessonComponents({ lesson, passedCheckIds, isSignedIn });
const totalChecks = lesson.meta.checks.length;
const passedCount = passedCheckIds.size;

Expand All @@ -49,6 +52,7 @@ export default async function LessonPage({

return (
<div className="px-6 py-6">
<RestoreScroll />
<div className="text-xs text-zinc-500">
<Link href="/lessons" className="hover:underline">
← All lessons
Expand Down Expand Up @@ -123,7 +127,7 @@ export default async function LessonPage({

<div className="flex flex-col gap-3 lg:sticky lg:top-6 lg:self-start lg:h-[calc(100dvh-3rem)]">
<Suspense fallback={<SandboxLoading />}>
<SandboxSection userId={session.user.id} lesson={lesson} />
<SandboxSection userId={session?.user.id ?? null} lesson={lesson} />
</Suspense>
</div>
</div>
Expand Down
38 changes: 32 additions & 6 deletions app/sign-in-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={onClick}
disabled={loading}
className="flex items-center justify-center gap-2 rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90 disabled:opacity-50"
className={
isInline
? "inline-flex items-center gap-1.5 rounded border border-black/10 px-2 py-0.5 text-[11px] font-medium hover:bg-black/[.04] disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/[.04]"
: "flex items-center justify-center gap-2 rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90 disabled:opacity-50"
}
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-4 w-4 fill-current"
className={isInline ? "h-3.5 w-3.5 fill-current" : "h-4 w-4 fill-current"}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
{loading ? "Redirecting…" : "Sign in with GitHub"}
{loading ? "Redirecting…" : children ?? "Sign in with GitHub"}
</button>
);
}
35 changes: 23 additions & 12 deletions components/lesson/Check.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,13 +10,17 @@ type Props = {
check: Check | undefined;
lessonSlug: string;
initiallyPassed: boolean;
isSignedIn: boolean;
callbackURL: string;
children?: ReactNode;
};

export function CheckCard({
check,
lessonSlug,
initiallyPassed,
isSignedIn,
callbackURL,
children,
}: Props) {
const [status, setStatus] = useState<Status>(
Expand Down Expand Up @@ -86,18 +91,24 @@ export function CheckCard({

{check && (
<div className="mt-3 flex items-center gap-3">
<button
type="button"
onClick={onRun}
disabled={!canRun}
className="inline-flex items-center gap-1.5 rounded-md border border-black/10 bg-white px-2.5 py-1 text-xs font-medium hover:bg-black/[.04] disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-zinc-950 dark:hover:bg-white/[.04]"
>
{status === "running"
? "Checking…"
: status === "pass"
? "Re-check"
: "Check"}
</button>
{isSignedIn ? (
<button
type="button"
onClick={onRun}
disabled={!canRun}
className="inline-flex items-center gap-1.5 rounded-md border border-black/10 bg-white px-2.5 py-1 text-xs font-medium hover:bg-black/[.04] disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-zinc-950 dark:hover:bg-white/[.04]"
>
{status === "running"
? "Checking…"
: status === "pass"
? "Re-check"
: "Check"}
</button>
) : (
<SignInButton variant="inline" callbackURL={callbackURL} preserveScroll>
Sign in to check
</SignInButton>
)}
</div>
)}
</aside>
Expand Down
22 changes: 22 additions & 0 deletions components/lesson/RestoreScroll.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 22 additions & 9 deletions components/lesson/RunBlock.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLPreElement>(null);

const onRun = () => {
Expand All @@ -17,14 +24,20 @@ export function RunBlock({ children }: { children: ReactNode }) {
<div className="not-prose my-5 overflow-hidden rounded-lg border border-black/10 dark:border-white/10">
<div className="flex items-center justify-between border-b border-black/5 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500 dark:border-white/5 dark:bg-zinc-900/60">
<span className="font-mono">sql</span>
<button
type="button"
onClick={onRun}
className="rounded border border-black/10 px-2 py-0.5 text-[11px] font-medium hover:bg-black/[.04] dark:border-white/10 dark:hover:bg-white/[.04]"
title="Run in your shell"
>
▶ Run
</button>
{isSignedIn ? (
<button
type="button"
onClick={onRun}
className="rounded border border-black/10 px-2 py-0.5 text-[11px] font-medium hover:bg-black/[.04] dark:border-white/10 dark:hover:bg-white/[.04]"
title="Run in your shell"
>
▶ Run
</button>
) : (
<SignInButton variant="inline" callbackURL={callbackURL} preserveScroll>
Sign in to run
</SignInButton>
)}
</div>
<pre
ref={preRef}
Expand Down
10 changes: 9 additions & 1 deletion components/lesson/SandboxSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import type { Lesson } from "@/lib/lessons";
import { BranchPanel } from "./BranchPanel";
import { SandboxPanel } from "./SandboxPanel";
import { SandboxSignInPrompt } from "./SandboxSignInPrompt";

/**
* Server component that does the slow Xata work for a lesson sandbox. Rendered
Expand All @@ -16,9 +17,16 @@ export async function SandboxSection({
userId,
lesson,
}: {
userId: string;
userId: string | null;
lesson: Lesson;
}) {
// Anonymous visitors can read the lesson but don't get a Xata branch.
if (!userId) {
return (
<SandboxSignInPrompt callbackURL={`/lessons/${lesson.meta.slug}`} />
);
}

if (
!process.env.XATA_API_KEY ||
!process.env.XATA_ORG_ID ||
Expand Down
32 changes: 32 additions & 0 deletions components/lesson/SandboxSignInPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { SignInButton } from "@/app/sign-in-button";

export function SandboxSignInPrompt({ callbackURL }: { callbackURL: string }) {
return (
<>
<div className="flex items-center justify-between gap-3 rounded-lg border border-black/10 bg-zinc-50 px-3 py-2 text-xs dark:border-white/10 dark:bg-zinc-900/40">
<span className="flex min-w-0 items-center gap-2 text-zinc-500">
<span
aria-hidden
className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-400"
/>
<span className="font-mono text-zinc-700 dark:text-zinc-300">
sandbox locked
</span>
</span>
</div>
<div className="relative h-[60dvh] min-h-[320px] lg:h-auto lg:flex-1 lg:min-h-0">
<div className="flex h-full w-full items-center justify-center rounded-lg border border-black/10 bg-[#09090b] dark:border-white/10">
<div className="flex max-w-xs flex-col items-center gap-4 px-6 text-center">
<p className="text-sm text-zinc-400">
Sign in to spin up your own Postgres sandbox and run the queries
for this lesson.
</p>
<SignInButton callbackURL={callbackURL} preserveScroll>
Sign in to start the sandbox
</SignInButton>
</div>
</div>
</div>
</>
);
}
14 changes: 12 additions & 2 deletions components/lesson/mdx-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import { RunBlock } from "./RunBlock";
type BuildOpts = {
lesson: Lesson;
passedCheckIds: Set<string>;
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);
Expand All @@ -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}
</CheckCard>
);
},
Run: ({ children }: { children: ReactNode }) => (
<RunBlock>{children}</RunBlock>
<RunBlock isSignedIn={isSignedIn} callbackURL={callbackURL}>
{children}
</RunBlock>
),
pre: (props: ComponentProps<"pre">) => (
<pre
Expand Down
Loading