Skip to contentVibraUI
Part of the Academy dashboardinstalls at /academy/courses/[id]

Course page

One course: the header with its level, lessons and instructor, four figures, twelve weeks of enrolment, the students on it with their progress, the instructor with a face, the completion funnel from enrolled to finished, and every lesson numbered with how many students are past it.

Open the live page

Installs at /academy/courses/[id], one static page per course through generateStaticParams; with no params — the docs preview renders the default export bare — it opens on the course with the most students on it. The page is a server component inside AppShell, with Courses current and a trail back to it: courseRecord reads the db.courses row, the db.members row that teaches it and the db.students holding a place on it — the roster, never more than the course's own enrolment figure, which counts everyone it has ever taken. Course records its lifetime enrolment and completion rate, not the weeks or the lessons, so the rest are rules over the rows: the funnel's finished step is the enrolment times the completion rate (the overview's rule), and its started step is the enrolment times the share of this course's own roster that has opened it, never below finished — the card says how many of the roster that is; a student is past progress × lessons ÷ 100 lessons, rounded down; the twelve complete weeks of enrolment run at the course's yearly pace (the enrolment over 52), scattered by seeded("academy-course/<id>/weeks"); and each lesson's kind and length come from seeded("academy-course/<id>/lessons") — an introduction first, a checkpoint quiz every fifth, a final project last. The roster sheds columns as its card narrows instead of scrolling sideways — Started below 46rem (a 1,280px screen), and on a phone the last visit and the status too, which then ride under the student's name. The lesson list shows eight and folds the rest out in place. The instructor's address is a mailto: link. Composes AppShell, PageHeader, Badge, StatCardGroup, StatCard, ChartCard, BarChart, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction, FunnelChart, StatList, UserCell, SimpleTable, Progress, StatusBadge and Button.

Preview

Install

npx shadcn@latest add @vibra/academy-course

Needs the @vibra registry in your components.json — set it up once.

Source

app/academy/courses/[id]/page.tsx
import { notFound } from "next/navigation"

import { NAV, ROUTES } from "@/lib/dashboards/academy/nav"
import { CATEGORY_LABELS, LEVEL_LABELS } from "@/lib/dashboards/academy/vocabulary"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { Badge } from "@/components/ui/badge"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { CompletionFunnel } from "./components/completion-funnel"
import { CourseFigures } from "./components/course-figures"
import { CourseRoster } from "./components/course-roster"
import { EnrolmentChart } from "./components/enrolment-chart"
import { InstructorCard } from "./components/instructor-card"
import { LessonList } from "./components/lesson-list"
import { courseIds, courseRecord, currentUser, fallbackId, lastUpdated, shellNotifications } from "./data"

/** One static page per course, which is what makes this a real detail route. */
export function generateStaticParams() {
  return courseIds().map((id) => ({ id }))
}

/**
 * One course. The route is `/academy/courses/[id]`, and `params` is optional
 * so the docs preview — which renders the default export with no props at
 * all — still has a course to show: without one it falls back to the course
 * with the most students on it. The page is a server component: it gathers
 * the course, its instructor and its roster in one read and hands the two
 * islands — the chart and the folding lesson list — plain rows.
 */
export default async function CoursePage({ params }: { params?: Promise<{ id: string }> }) {
  const { id } = (await params) ?? { id: fallbackId() }
  const record = await courseRecord(id)
  if (!record) notFound()

  const { course, instructor } = record

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.course}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
      breadcrumbs={[
        { title: NAV.brand.name, href: NAV.brand.href },
        { title: "Courses", href: ROUTES.courses },
        { title: course.title, href: record.href },
      ]}
    >
      <PageHeader
        backHref={ROUTES.courses}
        title={course.title}
        badge={<Badge variant="outline">{CATEGORY_LABELS[course.category] ?? course.category}</Badge>}
        description={`${LEVEL_LABELS[course.level] ?? course.level} · ${course.lessons} lessons · taught by ${instructor?.name ?? "Academy staff"}`}
        meta={lastUpdated(course)}
      />

      <CourseFigures record={record} />

      <div className="grid gap-4 lg:grid-cols-3">
        <div className="flex min-w-0 flex-col gap-4 lg:col-span-2">
          <EnrolmentChart weeks={record.weeks} />
          <CourseRoster rows={record.roster} />
        </div>
        <div className="flex min-w-0 flex-col gap-4">
          <InstructorCard instructor={instructor} />
          <CompletionFunnel funnel={record.funnel} />
          <LessonList lessons={record.lessons} roster={record.roster.length} />
        </div>
      </div>
    </AppShell>
  )
}
app/academy/courses/[id]/data.ts
/**
 * What /academy/courses/[id] reads. The record is a `db.courses` row; around
 * it are the `db.members` row that teaches it and the `db.students` who hold a
 * place on it — the roster. `Course` records its lifetime enrolment and its
 * completion rate, not the weeks the learners came in or the lessons they
 * passed, so the rest is rules over those rows:
 *
 * - the funnel runs enrolled → started → finished: finished is the enrolment
 *   times the completion rate (the overview's rule), and started is the
 *   enrolment times the share of this course's own roster that has opened it
 *   — never below finished, and the whole enrolment for a course with nobody
 *   on its roster to read;
 * - a lesson is passed by a student whose progress covers it — progress ×
 *   lessons ÷ 100, rounded down, is how many lessons they are past;
 * - the last twelve complete weeks of enrolment run at a year's pace — the
 *   enrolment over 52 — scattered by `seeded("academy-course/<id>/weeks")`;
 * - each lesson's kind and length come from `seeded("academy-course/<id>/lessons")`:
 *   an introduction first, a checkpoint quiz every fifth lesson, a final
 *   project last, and videos, exercises and readings between.
 *
 * "Now" is `REFERENCE_DATE`. The client islands import only the types below.
 */
import { formatDate, getInitials } from "@/lib/format"
import { courseHref } from "@/lib/dashboards/academy/vocabulary"
import {
  REFERENCE_DATE,
  db,
  intBetween,
  pickWeighted,
  seeded,
  type Course,
  type Member,
  type Student,
} from "@/lib/sample-data"

const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS

/** One student on the course, and their place on it. */
export type RosterRow = {
  id: string
  name: string
  email: string
  avatarUrl: string
  /** How far through this course, as a whole percentage. */
  progress: number
  startedAt: Date
  lastActiveAt: Date
  status: Student["status"]
}

export type LessonKind = "intro" | "video" | "exercise" | "reading" | "quiz" | "project"

export type Lesson = {
  number: number
  kind: LessonKind
  minutes: number
  /** Students on the roster past this lesson. */
  passed: number
}

export type WeekPoint = { week: string; enrolments: number }

export type Instructor = {
  name: string
  email: string
  avatarUrl?: string
  /** Courses in the catalogue they teach, this one included. */
  courses: number
  /** Every learner those courses have enrolled. */
  learners: number
  /** The mean rating of those courses. */
  rating: number
}

export type Funnel = {
  enrolled: number
  started: number
  finished: number
  /** The roster the started step is read off: how many hold a place, and how many have opened it. */
  roster: number
  opened: number
}

/** The course and everything the page says about it, gathered in one read. */
export type CourseRecord = {
  course: Course
  href: string
  instructor: Instructor | undefined
  roster: RosterRow[]
  /** The roster's mean progress, in whole percent; 0 with nobody on it. */
  averageProgress: number
  funnel: Funnel
  weeks: WeekPoint[]
  lessons: Lesson[]
}

/** Everyone holding a place on a course, furthest along first. */
function rosterOf(course: Course): RosterRow[] {
  return db.students
    .all()
    .flatMap((student) =>
      student.enrolled
        .filter((place) => place.courseId === course.id)
        .map((place) => ({
          id: student.id,
          name: student.name,
          email: student.email,
          avatarUrl: student.avatarUrl,
          progress: place.progress,
          startedAt: place.startedAt,
          lastActiveAt: student.lastActiveAt,
          status: student.status,
        }))
    )
    .sort((a, b) => b.progress - a.progress || a.name.localeCompare(b.name))
}

/**
 * The course the page falls back to when it is rendered without a route
 * param — which is what the docs preview does: the one with the most students
 * on it, the lowest id on a tie, because a record page is worth looking at
 * when it has a roster. Read per request, like every row here.
 */
export function fallbackId(): string {
  return db.courses
    .all()
    .sort((a, b) => rosterOf(b).length - rosterOf(a).length || a.id.localeCompare(b.id))[0].id
}

/** Every course id, for `generateStaticParams`. */
export function courseIds(): string[] {
  return db.courses.all().map((course) => course.id)
}

function funnelOf(course: Course, roster: RosterRow[]): Funnel {
  const finished = Math.round(course.enrolled * course.completionRate)
  const opened = roster.filter((row) => row.progress > 0).length
  const share = roster.length === 0 ? 1 : opened / roster.length
  const started = Math.max(finished, Math.round(course.enrolled * share))
  return { enrolled: course.enrolled, started, finished, roster: roster.length, opened }
}

const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** Monday of the week `REFERENCE_DATE` falls in, at midnight UTC. */
function thisMonday(): number {
  const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
  return today - ((new Date(today).getUTCDay() + 6) % 7) * DAY_MS
}

/** The last twelve complete weeks, oldest first, at a year's pace of the course's own enrolment. */
function weeksOf(course: Course): WeekPoint[] {
  const rand = seeded(`academy-course/${course.id}/weeks`)
  const monday = thisMonday()
  const pace = course.enrolled / 52
  return Array.from({ length: 12 }, (_, index) => ({
    week: WEEK_LABEL.format(new Date(monday - (12 - index) * WEEK_MS)),
    enrolments: Math.round(pace * (0.6 + rand() * 0.8)),
  }))
}

// Between the introduction, the quizzes and the project: mostly video.
const BODY_MIX: readonly (readonly [LessonKind, number])[] = [
  ["video", 0.55],
  ["exercise", 0.3],
  ["reading", 0.15],
]

// Minutes, by kind.
const LENGTH: Record<LessonKind, [number, number]> = {
  intro: [4, 8],
  video: [6, 18],
  exercise: [15, 35],
  reading: [5, 12],
  quiz: [8, 12],
  project: [45, 90],
}

/** The course's lessons in order, each with the students on the roster past it. */
function lessonsOf(course: Course, roster: RosterRow[]): Lesson[] {
  const rand = seeded(`academy-course/${course.id}/lessons`)
  const total = course.lessons
  return Array.from({ length: total }, (_, index) => {
    const number = index + 1
    const kind: LessonKind =
      number === 1 ? "intro" : number === total ? "project" : number % 5 === 0 ? "quiz" : pickWeighted(rand, BODY_MIX)
    const [low, high] = LENGTH[kind]
    return {
      number,
      kind,
      minutes: intBetween(rand, low, high),
      passed: roster.filter((row) => Math.floor((row.progress * total) / 100) >= number).length,
    }
  })
}

function instructorOf(course: Course): Instructor | undefined {
  const member = db.members.all().find((row) => row.id === course.instructorId)
  if (!member) return undefined
  const taught = db.courses.all().filter((row) => row.instructorId === member.id)
  return {
    name: member.name,
    email: member.email,
    avatarUrl: member.avatarUrl,
    courses: taught.length,
    learners: taught.reduce((sum, row) => sum + row.enrolled, 0),
    rating: taught.reduce((sum, row) => sum + row.rating, 0) / taught.length,
  }
}

/** The whole record, or undefined when the id names no course. */
export async function courseRecord(id: string): Promise<CourseRecord | undefined> {
  const course = await db.courses.get(id)
  if (!course) return undefined
  const roster = rosterOf(course)
  return {
    course,
    href: courseHref(course.id),
    instructor: instructorOf(course),
    roster,
    averageProgress:
      roster.length === 0 ? 0 : Math.round(roster.reduce((sum, row) => sum + row.progress, 0) / roster.length),
    funnel: funnelOf(course, roster),
    weeks: weeksOf(course),
    lessons: lessonsOf(course, roster),
  }
}

/** The line under the title: when the course was last touched. */
export function lastUpdated(course: Course): string {
  return `Updated ${formatDate(course.updatedAt, "medium", { timeZone: "UTC" })}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/academy/courses/[id]/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes: a course is read here, and its students
 * are enrolled from the Students page. A server action so the page can stay a
 * server component and still hand the shell something to call.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/academy/courses/[id]/components/completion-funnel.tsx
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { FunnelChart } from "@/components/ui/funnel-chart"

import { type Funnel } from "../data"

/**
 * Everyone the course has enrolled, how many opened it, and how many
 * finished — each step with its share of the first and the rate that went on
 * from the step before. The started step is this course's own roster scaled
 * to the enrolment, and the card says so under its title. No state, so it
 * renders on the server.
 */
export function CompletionFunnel({ funnel }: { funnel: Funnel }) {
  return (
    <Card data-widget="widget-academy-course-completion-funnel" role="region" aria-labelledby="course-funnel-title">
      <CardHeader>
        <CardTitle id="course-funnel-title">Completion funnel</CardTitle>
        <CardDescription className="tabular-nums">
          {funnel.roster === 0
            ? "Everyone enrolled, and how far they got."
            : `Started reads this course's roster: ${funnel.opened} of ${funnel.roster} have opened it.`}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <FunnelChart
          steps={[
            { label: "Enrolled", value: funnel.enrolled },
            { label: "Started", value: funnel.started },
            { label: "Finished", value: funnel.finished },
          ]}
          valueFormatter={(value) => formatNumber(value)}
        />
      </CardContent>
    </Card>
  )
}
app/academy/courses/[id]/components/course-figures.tsx
import { GraduationCapIcon, StarIcon, UsersIcon, UserRoundCheckIcon } from "lucide-react"

import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type CourseRecord } from "../data"

/**
 * The course in four numbers: everyone it has ever enrolled, the share who
 * finished, its rating, and the students on the roster now with how far
 * through they are on average. No state, so it renders on the server.
 */
export function CourseFigures({ record }: { record: CourseRecord }) {
  const { course, roster, averageProgress, funnel } = record

  return (
    <StatCardGroup data-widget="widget-academy-course-course-figures" columns={4} role="region" aria-label="The course in figures">
      <StatCard label="Enrolled" value={formatNumber(course.enrolled)} description="Learners, all time" icon={<UsersIcon />} />
      <StatCard
        label="Completion"
        value={formatPercent(course.completionRate, { maximumFractionDigits: 0 })}
        description={`${formatNumber(funnel.finished)} finished`}
        icon={<GraduationCapIcon />}
      />
      <StatCard label="Rating" value={course.rating.toFixed(1)} description="Out of 5" icon={<StarIcon />} />
      <StatCard
        label="On the roster"
        value={formatNumber(roster.length)}
        description={`${averageProgress}% through, on average`}
        icon={<UserRoundCheckIcon />}
      />
    </StatCardGroup>
  )
}
app/academy/courses/[id]/components/course-roster.tsx
import { STUDENT_STATUS_LABELS, STUDENT_STATUS_MAP } from "@/lib/dashboards/academy/vocabulary"
import { formatDate } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"

import { type RosterRow } from "../data"

const day = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })

/**
 * The students on the course now, furthest along first: each with a face, how
 * far through they are, when they took the place, when they were last in, and
 * where they stand. The course's enrolment figure counts everyone it has ever
 * taken; this is the part of it the academy is looking after today. No state,
 * so it renders on the server.
 */
export function CourseRoster({ rows }: { rows: RosterRow[] }) {
  return (
    <Card data-widget="widget-academy-course-course-roster" role="region" aria-labelledby="course-roster-title">
      <CardHeader>
        <CardTitle id="course-roster-title">Students on this course</CardTitle>
        <CardDescription className="tabular-nums">{`${rows.length} on the roster, furthest along first`}</CardDescription>
      </CardHeader>
      {/* A container, so the table sheds columns as its card narrows instead
          of scrolling sideways: the day each place was taken below 46rem (a
          1,280px screen), and below 32rem (a phone) the last visit and the
          status too, which then ride under the student's name. */}
      <CardContent className="@container/roster p-0">
        <SimpleTable
          size="sm"
          className="rounded-none border-0"
          rows={rows}
          rowKey="id"
          emptyMessage="Nobody is on this course right now."
          columns={[
            {
              key: "name",
              header: "Student",
              cell: (row) => (
                <UserCell
                  size="sm"
                  name={row.name}
                  src={row.avatarUrl}
                  description={
                    <>
                      <span className="@max-[32rem]/roster:hidden">{row.email}</span>
                      <span className="hidden @max-[32rem]/roster:inline">{`${STUDENT_STATUS_LABELS[row.status]} · last in ${day(row.lastActiveAt)}`}</span>
                    </>
                  }
                />
              ),
            },
            {
              key: "progress",
              header: "Progress",
              cell: (row) => (
                <span className="flex min-w-24 items-center gap-2 @[32rem]/roster:min-w-32">
                  <Progress value={row.progress} aria-label={`${row.name}'s progress`} className="flex-1" />
                  <span className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{`${row.progress}%`}</span>
                </span>
              ),
            },
            {
              key: "startedAt",
              header: "Started",
              align: "right",
              className: "@max-[46rem]/roster:hidden",
              cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{day(row.startedAt)}</span>,
            },
            {
              key: "lastActiveAt",
              header: "Last in",
              align: "right",
              className: "@max-[32rem]/roster:hidden",
              cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{day(row.lastActiveAt)}</span>,
            },
            {
              key: "status",
              header: "Status",
              className: "@max-[32rem]/roster:hidden",
              cell: (row) => (
                <StatusBadge status={row.status} label={STUDENT_STATUS_LABELS[row.status]} map={STUDENT_STATUS_MAP} />
              ),
            },
          ]}
        />
      </CardContent>
    </Card>
  )
}
app/academy/courses/[id]/components/course-vocabulary.ts
import {
  BookOpenTextIcon,
  CirclePlayIcon,
  FlagIcon,
  ListChecksIcon,
  PencilLineIcon,
  PresentationIcon,
  type LucideIcon,
} from "lucide-react"

/**
 * The words this page puts on a lesson's kind, and how a length is written.
 * Vocabulary, not data — it lives beside the islands that print it rather
 * than in `data.ts`, which reads `db` and must never reach the browser. The
 * words the whole dashboard shares — a course's category and level, a
 * student's status — are in `@/lib/dashboards/academy/vocabulary`.
 */
export const LESSON_KINDS: Record<string, { label: string; icon: LucideIcon }> = {
  intro: { label: "Course introduction", icon: PresentationIcon },
  video: { label: "Video lesson", icon: CirclePlayIcon },
  exercise: { label: "Exercise", icon: PencilLineIcon },
  reading: { label: "Reading", icon: BookOpenTextIcon },
  quiz: { label: "Checkpoint quiz", icon: ListChecksIcon },
  project: { label: "Final project", icon: FlagIcon },
}

/** "45 min", "1 h 20 min": a lesson's length, or the course's. */
export function duration(minutes: number): string {
  const hours = Math.floor(minutes / 60)
  const rest = minutes % 60
  if (hours === 0) return `${rest} min`
  return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`
}
app/academy/courses/[id]/components/enrolment-chart.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"

import { type WeekPoint } from "../data"

/**
 * New learners on the course each week, the last twelve complete weeks, one
 * grey series. The weeks arrive computed from the server page; this is an
 * island only because a chart draws in the browser.
 */
export function EnrolmentChart({ weeks }: { weeks: WeekPoint[] }) {
  const total = weeks.reduce((sum, point) => sum + point.enrolments, 0)

  return (
    <ChartCard
      data-widget="widget-academy-course-enrolment-chart"
      title="Enrolment by week"
      description="New learners each week, the last twelve complete weeks."
      actions={<span className="text-xs tabular-nums text-muted-foreground">{`${formatNumber(total)} in twelve weeks`}</span>}
      height={240}
    >
      <BarChart
        data={weeks}
        index="week"
        series={[{ key: "enrolments", label: "Enrolments" }]}
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
        height={240}
        legend="none"
      />
    </ChartCard>
  )
}
app/academy/courses/[id]/components/instructor-card.tsx
import { MailIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { StatList } from "@/components/ui/stat-list"
import { UserCell } from "@/components/ui/user-cell"

import { type Instructor } from "../data"

/**
 * Who teaches the course: their face, their address, and what they teach
 * across the catalogue. The address is a plain `mailto:` link styled as an
 * icon button — a link, because that is what it is. No state, so it renders
 * on the server.
 */
export function InstructorCard({ instructor }: { instructor: Instructor | undefined }) {
  return (
    <Card data-widget="widget-academy-course-instructor-card" role="region" aria-labelledby="course-instructor-title">
      <CardHeader>
        <CardTitle id="course-instructor-title">Instructor</CardTitle>
        {instructor ? (
          <CardAction>
            <a
              href={`mailto:${instructor.email}`}
              aria-label={`Email ${instructor.name}`}
              className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
            >
              <MailIcon aria-hidden="true" />
            </a>
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {instructor ? (
          <>
            <UserCell name={instructor.name} src={instructor.avatarUrl} email={instructor.email} />
            <StatList
              size="sm"
              divided
              items={[
                { label: "Courses", value: formatNumber(instructor.courses) },
                { label: "Learners", value: formatNumber(instructor.learners) },
                { label: "Rating", value: instructor.rating.toFixed(1) },
              ]}
            />
          </>
        ) : (
          <p className="text-sm text-muted-foreground">Nobody teaches this course yet.</p>
        )}
      </CardContent>
    </Card>
  )
}
app/academy/courses/[id]/components/lesson-list.tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"

import { type Lesson } from "../data"

import { LESSON_KINDS, duration } from "./course-vocabulary"

const SHOWN = 8

export type LessonListProps = {
  lessons: Lesson[]
  /** How many students are on the roster — the whole a lesson's count is out of. */
  roster: number
}

/**
 * The course, lesson by lesson: its number, its kind and its length, and how
 * many of the students on the roster are past it — read down the column, the
 * count is where the course loses people. Eight at first; the button folds the
 * rest out in place.
 */
export function LessonList({ lessons, roster }: LessonListProps) {
  const [open, setOpen] = React.useState(false)
  const shown = open ? lessons : lessons.slice(0, SHOWN)
  const total = lessons.reduce((sum, lesson) => sum + lesson.minutes, 0)

  return (
    <Card data-widget="widget-academy-course-lesson-list" role="region" aria-labelledby="course-lessons-title">
      <CardHeader>
        <CardTitle id="course-lessons-title">Lessons</CardTitle>
        <CardDescription className="tabular-nums">{`${lessons.length} lessons · ${duration(total)}`}</CardDescription>
      </CardHeader>
      <CardContent className="p-0">
        <ol data-slot="lesson-list" className="flex flex-col">
          {shown.map((lesson) => {
            const kind = LESSON_KINDS[lesson.kind]
            const Icon = kind.icon
            return (
              <li key={lesson.number} className="flex items-center gap-3 border-b px-(--card-spacing) py-2.5 last:border-b-0">
                <span className="w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{lesson.number}</span>
                <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
                <span className="flex min-w-0 flex-1 flex-col">
                  <span className="truncate text-sm font-medium">{kind.label}</span>
                  <span className="text-xs tabular-nums text-muted-foreground">{duration(lesson.minutes)}</span>
                </span>
                <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
                  {`${lesson.passed} of ${roster}`}
                  <span className="sr-only"> students past it</span>
                </span>
              </li>
            )
          })}
        </ol>
      </CardContent>
      {lessons.length > SHOWN ? (
        <CardFooter>
          <Button size="sm" variant="ghost" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
            {open ? "Show fewer" : `Show all ${lessons.length} lessons`}
          </Button>
        </CardFooter>
      ) : null}
    </Card>
  )
}