# Lesson player

> One lesson's content with a Mark as done button, or its quiz, which the database grades on submit.

- Block: [courses](https://bettersupabase.com/blocks/courses.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/lesson-player

## Install the block

```bash
npx better-supabase add courses
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button card checkbox field input select skeleton textarea
```

From `apps/examples/nextjs/src/features/courses/components/lesson-player.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/courses/components/lesson-player.tsx):

```tsx
import type { CourseLessonKind } from "better-supabase/blocks/courses";

import { ArrowLeftIcon, TriangleAlertIcon } from "lucide-react";
import { getExtracted } from "next-intl/server";
import { refresh } from "next/cache";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "@/i18n/navigation";

import { getLesson } from "../courses-queries";
import { CompleteLessonButton } from "./course-controls";
import { QuizForm } from "./quiz-form";

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: one lesson's content, or its quiz. */
export async function LessonPlayer({
  params,
}: {
  params: Promise<{ courseId: string; lessonId: string }>;
}) {
  const { courseId, lessonId } = await params;

  const [view, t] = await Promise.all([
    getLesson(lessonId),
    getExtracted("courses"),
  ]);

  const back = (
    <Link
      href={`/courses/${courseId}`}
      className={buttonVariants({
        variant: "ghost",
        size: "sm",
        className: "w-fit",
      })}
    >
      <ArrowLeftIcon />
      {t("Course outline")}
    </Link>
  );

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <div data-recipe="lesson-player" className="flex flex-col gap-4">
        {back}
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>{t("The lesson didn't load")}</AlertTitle>
          <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
          <AlertAction>
            <form action={retry}>
              <Button size="sm" variant="outline" type="submit">
                {t("Retry")}
              </Button>
            </form>
          </AlertAction>
        </Alert>
      </div>
    );
  }

  const lesson = view.data;

  const kindLabels: Record<CourseLessonKind, string> = {
    content: t("Reading"),
    video: t("Video"),
    quiz: t("Quiz"),
  };

  return (
    <div data-recipe="lesson-player" className="flex flex-col gap-4">
      {back}
      <Card>
        <CardHeader>
          <div className="flex flex-wrap items-center gap-2">
            <CardTitle>{lesson.title}</CardTitle>
            <Badge variant="outline">{kindLabels[lesson.kind]}</Badge>
            {lesson.done ? (
              <Badge variant="success-light">{t("Done")}</Badge>
            ) : null}
          </div>
          {lesson.lastScore === null ? null : (
            <CardDescription>
              {t("Last score: {score, number} of {max, number}", {
                score: lesson.lastScore.score,
                max: lesson.lastScore.max,
              })}
            </CardDescription>
          )}
        </CardHeader>
        {lesson.kind === "quiz" ? (
          lesson.quiz === null || lesson.quiz.length === 0 ? (
            <CardContent>
              <Alert variant="warning">
                <TriangleAlertIcon />
                <AlertTitle>{t("This quiz has no questions")}</AlertTitle>
                <AlertDescription>
                  {t("A manager publishes the quiz form first.")}
                </AlertDescription>
              </Alert>
            </CardContent>
          ) : (
            <QuizForm lessonId={lesson.id} fields={lesson.quiz} />
          )
        ) : (
          <>
            <CardContent className="flex flex-col gap-4">
              {lesson.text === null ? null : (
                <p className="text-sm whitespace-pre-line">{lesson.text}</p>
              )}
              {lesson.mediaUrl === null ? null : (
                <a
                  href={lesson.mediaUrl}
                  target="_blank"
                  rel="noreferrer"
                  className="text-sm text-primary transition-colors hover:text-primary/80"
                >
                  {t("Open the video")}
                </a>
              )}
            </CardContent>
            {lesson.done ? null : (
              <CardFooter className="flex justify-end">
                <CompleteLessonButton lessonId={lesson.id} />
              </CardFooter>
            )}
          </>
        )}
      </Card>
    </div>
  );
}

export function LessonPlayerSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Lesson player" in a React web app that uses shadcn/ui components. One lesson's content with a Mark as done button, or its quiz, which the database grades on submit.

It uses the better-supabase `courses` block (Courses). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add courses` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button card checkbox field input select skeleton textarea`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/courses/components/lesson-player.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/lesson-player.md
- Block docs: https://bettersupabase.com/docs/blocks/courses
```
