/academy/coursesCourse catalogue
Every course in the catalogue — category, level, the instructor with a face, lessons, learners enrolled, the share who finish and the rating — with the catalogue added up in four figures, facets on category and level, and each course opening its own page.
Installs at /academy/courses. The page is a server component inside AppShell: catalogue() reads every db.courses row with the db.members row that teaches it, and catalogueTotals() adds the catalogue up — the courses and their categories, every learner the courses have ever enrolled, how many of those finished (each course's enrolment times its completion rate, the rule the overview's success rate reads, so the two pages agree) and the mean rating. The island searches the titles, filters on category and level, sorts every column and pages the rows, the most-taken course first. A course has no logo, so its initial in an ink square stands for it, and its title is a next/link to the course's own page at /academy/courses/<id>, with its level and lesson count under it: their own columns open hidden — the Level facet still filters its column and the View menu brings either back — so the table fits a 1,280px screen without scrolling sideways; on a phone it keeps the course and its completion, with the category and the enrolment under the title. Composes AppShell, PageHeader, StatCardGroup, StatCard, DataTable, DataTableColumnHeader, Badge, UserCell, NumberCell, PercentCell and Rating.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/academy/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { CatalogueFigures } from "./components/catalogue-figures"
import { CatalogueTable } from "./components/catalogue-table"
import { catalogue, catalogueTotals, currentUser, lastUpdated, shellNotifications } from "./data"
/**
* The catalogue. The page is a server component: it reads every course and
* its instructor through `db`, adds the catalogue up for the four figures, and
* hands the rows to the island that searches, filters and pages them. Each
* course's title opens its own page.
*/
export default function CoursesPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.courses}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="academy-courses-title"
title="Courses"
description="Every course in the catalogue: who teaches it, how many have taken it, and how many finish."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<CatalogueFigures totals={catalogueTotals()} />
<CatalogueTable titleId="academy-courses-title" rows={catalogue()} />
</div>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/academy-coursesNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/academy/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { CatalogueFigures } from "./components/catalogue-figures"
import { CatalogueTable } from "./components/catalogue-table"
import { catalogue, catalogueTotals, currentUser, lastUpdated, shellNotifications } from "./data"
/**
* The catalogue. The page is a server component: it reads every course and
* its instructor through `db`, adds the catalogue up for the four figures, and
* hands the rows to the island that searches, filters and pages them. Each
* course's title opens its own page.
*/
export default function CoursesPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.courses}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="academy-courses-title"
title="Courses"
description="Every course in the catalogue: who teaches it, how many have taken it, and how many finish."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<CatalogueFigures totals={catalogueTotals()} />
<CatalogueTable titleId="academy-courses-title" rows={catalogue()} />
</div>
</AppShell>
)
}/**
* What /academy/courses reads. The catalogue is `db.courses`; each course's
* instructor is the `db.members` row it names. The four figures add the
* catalogue's own numbers up: the learners every course has ever enrolled,
* and how many of them its completion rate says finished — the rule the
* overview's success rate reads, so the two pages agree. "Now" is
* `REFERENCE_DATE`.
*
* The client islands import only the types below. The words the page puts on
* a category or a level, and the href of a course's page, are the dashboard's
* own, in `@/lib/dashboards/academy/vocabulary`, so nothing
* that reaches `db` is ever pulled into the browser.
*/
import { formatDate, getInitials } from "@/lib/format"
import { courseHref } from "@/lib/dashboards/academy/vocabulary"
import { REFERENCE_DATE, db, type Course, type Member } from "@/lib/sample-data"
/** One course as the catalogue prints it. */
export type CatalogueRow = {
id: string
title: string
/** The tile's letter: a course has no logo, so its initial in ink stands for it. */
initial: string
category: Course["category"]
level: Course["level"]
instructor: string
instructorAvatar?: string
lessons: number
/** Learners the course has ever enrolled. */
enrolled: number
/** The share of them who finished, 0–1. */
completionRate: number
rating: number
/** The course's own page. */
href: string
}
/** Every course in the catalogue; the table sorts, filters and pages them itself. */
export function catalogue(): CatalogueRow[] {
// Read per call, never held at module scope: an instructor renamed since the
// server started is the name on the next render.
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.courses.all().map((course) => {
const instructor = members.get(course.instructorId)
return {
id: course.id,
title: course.title,
initial: course.title.charAt(0).toUpperCase(),
category: course.category,
level: course.level,
instructor: instructor?.name ?? "Academy staff",
instructorAvatar: instructor?.avatarUrl,
lessons: course.lessons,
enrolled: course.enrolled,
completionRate: course.completionRate,
rating: course.rating,
href: courseHref(course.id),
}
})
}
export type CatalogueTotals = {
courses: number
categories: number
/** Every enrolment the catalogue has ever taken. */
learners: number
/** How many of those finished, course by course. */
finished: number
/** The mean of the courses' ratings, out of five. */
rating: number
/** Courses rated 4.5 or more. */
topRated: number
}
/** The four figures over the catalogue. */
export function catalogueTotals(): CatalogueTotals {
const courses = db.courses.all()
return {
courses: courses.length,
categories: new Set(courses.map((course) => course.category)).size,
learners: courses.reduce((sum, course) => sum + course.enrolled, 0),
finished: courses.reduce((sum, course) => sum + Math.round(course.enrolled * course.completionRate), 0),
rating: courses.length === 0 ? 0 : courses.reduce((sum, course) => sum + course.rating, 0) / courses.length,
topRated: courses.filter((course) => course.rating >= 4.5).length,
}
}
/** The line under the title: how many courses, and when they were read. */
export function lastUpdated(): string {
return `${db.courses.all().length} courses · synced ${formatDate(REFERENCE_DATE, "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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes: the catalogue is read here, not edited. A
* server action so the page can stay a server component and still hand the
* shell something to call, and a `Result` like every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import Link from "next/link"
import { CATEGORY_LABELS, LEVEL_LABELS } from "@/lib/dashboards/academy/vocabulary"
import { formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { Rating } from "@/components/ui/rating"
import { NumberCell, PercentCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type CatalogueRow } from "../data"
/**
* The catalogue's columns. The course's title is the link to its page — a
* real `next/link`, so the row opens the course with the pointer, the
* keyboard, or a middle click into a new tab — with its level and lessons
* under it; those two keep columns of their own, which the table opens
* hidden, for the Level facet and the View menu. The numbers are the
* course's own: what it has ever enrolled, the share who finished, and its
* rating.
*
* On a phone the table keeps the course and its completion; the category and
* the enrolment ride under the title instead of in columns screens to the
* right, and the instructor and the rating wait for a wider screen.
*/
export const CATALOGUE_COLUMNS: DataTableColumnDef<CatalogueRow>[] = [
{
accessorKey: "title",
header: ({ column }) => <DataTableColumnHeader column={column} title="Course" />,
cell: ({ row }) => (
<span className="flex max-w-48 min-w-40 items-center gap-3 md:max-w-none md:min-w-48">
<span
aria-hidden="true"
data-slot="course-initial"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-foreground text-sm font-semibold text-background"
>
{row.original.initial}
</span>
<span className="flex min-w-0 flex-col">
<Link href={row.original.href} className="truncate rounded-sm font-medium underline-offset-4 hover:underline focus-ring">
{row.original.title}
</Link>
<span className="truncate text-xs tabular-nums text-muted-foreground">
<span className="md:hidden">
{`${CATEGORY_LABELS[row.original.category]} · ${formatNumber(row.original.enrolled)} enrolled`}
</span>
<span className="hidden md:inline">
{`${LEVEL_LABELS[row.original.level] ?? row.original.level} · ${row.original.lessons} lessons`}
</span>
</span>
</span>
</span>
),
meta: { label: "Course" },
},
{
accessorKey: "category",
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
cell: ({ row }) => <Badge variant="outline">{CATEGORY_LABELS[row.original.category] ?? row.original.category}</Badge>,
meta: { label: "Category", className: "hidden md:table-cell" },
},
{
accessorKey: "level",
header: ({ column }) => <DataTableColumnHeader column={column} title="Level" />,
cell: ({ row }) => <span className="text-muted-foreground">{LEVEL_LABELS[row.original.level] ?? row.original.level}</span>,
meta: { label: "Level" },
},
{
accessorKey: "instructor",
header: ({ column }) => <DataTableColumnHeader column={column} title="Instructor" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.instructor} src={row.original.instructorAvatar} className="whitespace-nowrap" />
),
meta: { label: "Instructor", className: "hidden md:table-cell" },
},
{
accessorKey: "lessons",
header: ({ column }) => <DataTableColumnHeader column={column} title="Lessons" />,
cell: ({ row }) => <NumberCell value={row.original.lessons} />,
meta: { align: "right", label: "Lessons" },
},
{
accessorKey: "enrolled",
header: ({ column }) => <DataTableColumnHeader column={column} title="Enrolled" />,
cell: ({ row }) => <NumberCell value={row.original.enrolled} />,
meta: { align: "right", label: "Enrolled", className: "hidden md:table-cell" },
},
{
accessorKey: "completionRate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Completion" />,
cell: ({ row }) => <PercentCell value={row.original.completionRate} showBar className="min-w-20" />,
meta: { align: "right", label: "Completion" },
},
{
accessorKey: "rating",
header: ({ column }) => <DataTableColumnHeader column={column} title="Rating" />,
cell: ({ row }) => (
<span className="flex items-center justify-end gap-2">
<Rating value={row.original.rating} size="sm" label={`${row.original.title} rating`} />
<span className="text-xs tabular-nums text-muted-foreground">{row.original.rating.toFixed(1)}</span>
</span>
),
meta: { align: "right", label: "Rating", className: "hidden md:table-cell" },
},
]import { BookOpenIcon, GraduationCapIcon, StarIcon, UsersIcon } 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 CatalogueTotals } from "../data"
/**
* The catalogue in four numbers: its size, everyone it has enrolled, the
* share of them who finished, and how it is rated. No state, so it renders on
* the server.
*/
export function CatalogueFigures({ totals }: { totals: CatalogueTotals }) {
const completion = totals.learners === 0 ? 0 : totals.finished / totals.learners
return (
<StatCardGroup data-widget="widget-academy-courses-catalogue-figures" columns={4} role="region" aria-label="The catalogue in figures">
<StatCard
label="Courses"
value={formatNumber(totals.courses)}
description={`Across ${totals.categories} categories`}
icon={<BookOpenIcon />}
/>
<StatCard
label="Enrolled"
value={formatNumber(totals.learners)}
description="Learners, all time"
icon={<UsersIcon />}
/>
<StatCard
label="Completion"
value={formatPercent(completion, { maximumFractionDigits: 0 })}
description={`${formatNumber(totals.finished)} finished`}
icon={<GraduationCapIcon />}
/>
<StatCard
label="Rating"
value={totals.rating.toFixed(1)}
description={`${totals.topRated} rated 4.5 or more`}
icon={<StarIcon />}
/>
</StatCardGroup>
)
}"use client"
import * as React from "react"
import type { ColumnVisibilityState, Updater } from "@tanstack/react-table"
import { CATEGORY_LABELS, LEVEL_LABELS } from "@/lib/dashboards/academy/vocabulary"
import { DataTable, type DataTableFacet } from "@/components/ui/data-table"
import { type CatalogueRow } from "../data"
import { CATALOGUE_COLUMNS } from "./catalogue-columns"
// The facets print the column's own words, so "Web development" is one thing
// in the filter menu and in the cell.
const FACETS: DataTableFacet<CatalogueRow>[] = [
{
columnId: "category",
title: "Category",
options: Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })),
},
{
columnId: "level",
title: "Level",
options: Object.entries(LEVEL_LABELS).map(([value, label]) => ({ value, label })),
},
]
/**
* The catalogue. The rows arrive whole from the server page — a catalogue is
* small — and the table searches, filters, sorts and pages them here, the
* most-taken course first. The level and the lesson count ride under each
* title, so their own columns open hidden: the table fits a 1,280px screen
* without scrolling sideways, the Level facet still filters its column, and
* the View menu brings either back.
*/
export function CatalogueTable({ titleId, rows }: { titleId: string; rows: CatalogueRow[] }) {
const [visibility, setVisibility] = React.useState<ColumnVisibilityState>({ level: false, lessons: false })
const onVisibilityChange = React.useCallback(
(updater: Updater<ColumnVisibilityState>) =>
setVisibility((current) => (typeof updater === "function" ? updater(current) : updater)),
[]
)
return (
<DataTable
aria-labelledby={titleId}
columns={CATALOGUE_COLUMNS}
data={rows}
state={{ columnVisibility: visibility }}
onColumnVisibilityChange={onVisibilityChange}
getRowId={(row) => row.id}
searchKey="title"
searchPlaceholder="Search courses"
facets={FACETS}
enableRowSelection={false}
initialSorting={[{ id: "enrolled", desc: true }]}
pageSize={10}
pageSizeOptions={[10, 25, 50]}
emptyMessage="No course matches these filters."
/>
)
}