RegistryUI recipes
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; the courses block must be installed (npx better-supabase add courses). Its page on bettersupabase.com is /recipes/course-outline.
Web, with shadcn components:
npx shadcn add alert badge button card empty progress skeletonimport 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>
);
}Last updated on