Lesson player
One lesson's content with a Mark as done button, or its quiz, which the database grades on submit. It loads its data on the server through the block's client.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add alert badge button card checkbox field input select skeleton textareaInstall the glue
Install the block with
npx better-supabase add courses. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the screen
React and shadcn
View in the examplelesson-player.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" />; }