Course outline
A course's modules and lessons in rank order, with the caller's progress, certificate and an Enroll button. 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 empty progress skeletonInstall 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 component
React and shadcn
View in the examplecourse-outline.tsx import type { CourseLessonKind, CourseStatus, } from "better-supabase/blocks/courses"; import type { LucideIcon } from "lucide-react"; import { AwardIcon, CircleCheckIcon, CircleIcon, FileTextIcon, ListChecksIcon, PlayCircleIcon, 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, CardHeader, CardTitle, } from "@/components/ui/card"; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { Link } from "@/i18n/navigation"; import { getOutline } from "../courses-queries"; import { EnrollButton } from "./course-controls"; // 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(); } const statusVariants = { draft: "secondary", published: "success-light", archived: "outline", } as const satisfies Record<CourseStatus, string>; const kindIcons = { content: FileTextIcon, video: PlayCircleIcon, quiz: ListChecksIcon, } as const satisfies Record<CourseLessonKind, LucideIcon>; /** * Render inside `<Suspense>`: a course's modules, lessons and the caller's * progress. */ export async function CourseOutline({ params, }: { params: Promise<{ courseId: string }>; }) { const { courseId } = await params; const [view, t] = await Promise.all([ getOutline(courseId), getExtracted("courses"), ]); if (view.kind === "no-organization") { return null; } if (view.kind === "error") { return ( <Alert variant="destructive" data-recipe="course-outline"> <TriangleAlertIcon /> <AlertTitle>{t("The course 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> ); } const course = view.data; const learner = course.learner; const statusLabels: Record<CourseStatus, string> = { draft: t("Draft"), published: t("Published"), archived: t("Archived"), }; const kindLabels: Record<CourseLessonKind, string> = { content: t("Reading"), video: t("Video"), quiz: t("Quiz"), }; const next = course.modules .flatMap((module) => module.lessons) .find((lesson) => !lesson.done); return ( <div data-recipe="course-outline" className="flex flex-col gap-6"> <div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> <h2 className="text-lg font-semibold">{course.title}</h2> <Badge variant={statusVariants[course.status]}> {statusLabels[course.status]} </Badge> </div> {course.summary ? ( <p className="text-sm text-muted-foreground">{course.summary}</p> ) : null} </div> {learner === null ? ( course.status === "published" ? ( <EnrollButton courseId={course.id} /> ) : null ) : next === undefined ? null : ( <Link href={`/courses/${course.id}/lessons/${next.id}`} className={buttonVariants()} > <PlayCircleIcon /> {t("Continue")} </Link> )} </div> <div className="grid gap-6 lg:grid-cols-3"> <div className="flex flex-col gap-4 lg:col-span-2"> {course.modules.length === 0 ? ( <Empty className="border"> <EmptyHeader> <EmptyMedia variant="icon"> <ListChecksIcon /> </EmptyMedia> <EmptyTitle>{t("No lessons yet")}</EmptyTitle> <EmptyDescription> {t("Lessons show up here once a manager adds them.")} </EmptyDescription> </EmptyHeader> <EmptyContent> <Link href="/courses" className={buttonVariants({ variant: "outline" })} > {t("All courses")} </Link> </EmptyContent> </Empty> ) : ( course.modules.map((module) => ( <Card key={module.id}> <CardHeader> <CardTitle>{module.title}</CardTitle> <CardDescription> {t("{count, plural, one {# lesson} other {# lessons}}", { count: module.lessons.length, })} </CardDescription> </CardHeader> <CardContent> <ul className="divide-y"> {module.lessons.map((lesson) => { const KindIcon = kindIcons[lesson.kind]; return ( <li key={lesson.id} className="flex items-center gap-3 py-2" > {lesson.done ? ( <CircleCheckIcon className="size-4 text-success" aria-label={t("Done")} /> ) : ( <CircleIcon className="size-4 text-muted-foreground" aria-label={t("Not done")} /> )} {learner === null ? ( <span className="flex-1 text-sm"> {lesson.title} </span> ) : ( <Link href={`/courses/${course.id}/lessons/${lesson.id}`} className="flex-1 text-sm transition-colors hover:text-primary" > {lesson.title} </Link> )} <span className="flex items-center gap-1 text-xs text-muted-foreground"> <KindIcon className="size-3.5" /> {kindLabels[lesson.kind]} </span> {lesson.minutes === null ? null : ( <span className="text-xs text-muted-foreground"> {t("{minutes, number} min", { minutes: lesson.minutes, })} </span> )} {lesson.required ? null : ( <Badge variant="outline">{t("Optional")}</Badge> )} </li> ); })} </ul> </CardContent> </Card> )) )} </div> <Card> <CardHeader> <CardTitle>{t("Your progress")}</CardTitle> <CardDescription> {learner === null ? t("Enroll to track your progress.") : t("{done, number} of {total, number} lessons done", { done: learner.completed, total: learner.total, })} </CardDescription> </CardHeader> {learner === null ? null : ( <CardContent className="flex flex-col gap-4"> <Progress value={ learner.total === 0 ? 0 : (learner.completed / learner.total) * 100 } aria-label={t("Your progress")} /> {learner.certificateCode === null ? null : ( <div className="flex items-center gap-2 text-sm"> <AwardIcon className="size-4 text-success" /> <span>{t("Certificate")}</span> <code className="text-xs text-muted-foreground"> {learner.certificateCode} </code> </div> )} </CardContent> )} </Card> </div> </div> ); } export function CourseOutlineSkeleton() { return ( <div className="flex flex-col gap-6" aria-busy="true"> <Skeleton className="h-8 w-64" /> <div className="grid gap-6 lg:grid-cols-3"> <Skeleton className="h-64 rounded-xl lg:col-span-2" /> <Skeleton className="h-40 rounded-xl" /> </div> </div> ); }