RegistryUI recipes
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; the courses block must be installed (npx better-supabase add courses). Its page on bettersupabase.com is /recipes/lesson-player.
Web, with shadcn components:
npx shadcn add alert badge button card checkbox field input select skeleton textareaimport 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" />;
}Last updated on