{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"course-player","type":"registry:ui","title":"CoursePlayer","description":"The course itself \u2014 modules, lessons, progress, what is next, and the graded test.","package":"classroom","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":["types"],"files":[{"path":"components\/fancy\/course-player\/CoursePlayer.tsx","content":"import { useMemo, useState } from 'react';\r\nimport {\r\n    Badge,\r\n    Breadcrumbs,\r\n    Button,\r\n    Card,\r\n    cn,\r\n    Heading,\r\n    Progress,\r\n    Sidebar,\r\n    Text,\r\n} from '@particle-academy\/react-fancy';\r\nimport { LessonView } from '.\/LessonView';\r\nimport { TestRunner } from '.\/TestRunner';\r\nimport type {\r\n    AnswerInput,\r\n    Course,\r\n    Enrollment,\r\n    Lesson,\r\n    Test,\r\n    TestAttempt,\r\n} from '..\/types';\r\n\r\nexport interface CoursePlayerProps {\r\n    course: Course;\r\n    enrollment: Enrollment;\r\n    completedLessonIds: Set<number>;\r\n    onMarkLessonComplete: (lesson: Lesson) => void | Promise<void>;\r\n    onStartAttempt: (test: Test) => Promise<TestAttempt>;\r\n    onSubmitAttempt: (attempt: TestAttempt, answers: AnswerInput[]) => Promise<TestAttempt>;\r\n}\r\n\r\ntype Selection =\r\n    | { kind: 'lesson'; lesson: Lesson }\r\n    | { kind: 'test'; test: Test; attempt: TestAttempt | null };\r\n\r\nexport function CoursePlayer({\r\n    course,\r\n    completedLessonIds,\r\n    onMarkLessonComplete,\r\n    onStartAttempt,\r\n    onSubmitAttempt,\r\n}: CoursePlayerProps) {\r\n    const allTests = useMemo(() => collectTests(course), [course]);\r\n    const lessons = course.lessons ?? [];\r\n    const [selection, setSelection] = useState<Selection | null>(() => {\r\n        const first = lessons[0];\r\n        return first ? { kind: 'lesson', lesson: first } : null;\r\n    });\r\n\r\n    const completedLessonCount = lessons.filter((l) => completedLessonIds.has(l.id)).length;\r\n    const totalCount = lessons.length + allTests.length;\r\n    const completedCount = completedLessonCount;\r\n    const percentComplete = totalCount > 0 ? Math.round((completedCount \/ totalCount) * 100) : 0;\r\n\r\n    function selectLesson(lesson: Lesson): void {\r\n        setSelection({ kind: 'lesson', lesson });\r\n    }\r\n\r\n    async function selectTest(test: Test): Promise<void> {\r\n        const attempt = await onStartAttempt(test);\r\n        setSelection({ kind: 'test', test: attempt.test ?? test, attempt });\r\n    }\r\n\r\n    const currentLessonIdx =\r\n        selection?.kind === 'lesson'\r\n            ? lessons.findIndex((l) => l.id === selection.lesson.id)\r\n            : -1;\r\n\r\n    return (\r\n        <div className=\"grid grid-cols-1 md:grid-cols-[300px_1fr] gap-6 items-start\">\r\n            <Card\r\n                variant=\"outlined\"\r\n                padding=\"none\"\r\n                className=\"!rounded-xl !shadow-sm overflow-hidden md:sticky md:top-4\"\r\n            >\r\n                <div className=\"px-5 py-4 border-b border-secondary-200\">\r\n                    <Heading as=\"h2\" size=\"md\" weight=\"bold\">\r\n                        {course.title}\r\n                    <\/Heading>\r\n                    {course.description && (\r\n                        <Text size=\"sm\" color=\"muted\" className=\"!mt-1 line-clamp-2\">\r\n                            {course.description}\r\n                        <\/Text>\r\n                    )}\r\n                    <div className=\"mt-3 grid gap-1.5\">\r\n                        <Progress\r\n                            value={percentComplete}\r\n                            max={100}\r\n                            color=\"red\"\r\n                            size=\"sm\"\r\n                        \/>\r\n                        <div className=\"flex items-center justify-between\">\r\n                            <Text size=\"xs\" color=\"muted\">\r\n                                {completedLessonCount}\/{lessons.length} lessons\r\n                            <\/Text>\r\n                            <Text size=\"xs\" weight=\"semibold\" className=\"!text-brand\">\r\n                                {percentComplete}%\r\n                            <\/Text>\r\n                        <\/div>\r\n                    <\/div>\r\n                <\/div>\r\n\r\n                <div className=\"px-2 py-3\">\r\n                    <Sidebar>\r\n                        {lessons.length > 0 && (\r\n                            <Sidebar.Group label=\"Lessons\">\r\n                                {lessons.map((lesson, i) => {\r\n                                    const active =\r\n                                        selection?.kind === 'lesson' &&\r\n                                        selection.lesson.id === lesson.id;\r\n                                    const done = completedLessonIds.has(lesson.id);\r\n                                    return (\r\n                                        <Sidebar.Item\r\n                                            key={lesson.id}\r\n                                            active={active}\r\n                                            icon={\r\n                                                <LessonNumber\r\n                                                    n={i + 1}\r\n                                                    done={done}\r\n                                                    active={active}\r\n                                                \/>\r\n                                            }\r\n                                            badge={\r\n                                                done ? (\r\n                                                    <Badge\r\n                                                        color=\"green\"\r\n                                                        variant=\"soft\"\r\n                                                        size=\"sm\"\r\n                                                    >\r\n                                                        \u2713\r\n                                                    <\/Badge>\r\n                                                ) : undefined\r\n                                            }\r\n                                            onClick={() => selectLesson(lesson)}\r\n                                            className={cn(\r\n                                                active &&\r\n                                                    '!bg-primary-50 !text-brand !font-semibold',\r\n                                            )}\r\n                                        >\r\n                                            <span className=\"truncate\">{lesson.title}<\/span>\r\n                                        <\/Sidebar.Item>\r\n                                    );\r\n                                })}\r\n                            <\/Sidebar.Group>\r\n                        )}\r\n\r\n                        {allTests.length > 0 && (\r\n                            <Sidebar.Group label=\"Assessments\">\r\n                                {allTests.map((test) => {\r\n                                    const active =\r\n                                        selection?.kind === 'test' &&\r\n                                        selection.test.id === test.id;\r\n                                    return (\r\n                                        <Sidebar.Item\r\n                                            key={test.id}\r\n                                            active={active}\r\n                                            icon={<TestIcon \/>}\r\n                                            badge={\r\n                                                test.is_final ? (\r\n                                                    <Badge\r\n                                                        color=\"amber\"\r\n                                                        variant=\"soft\"\r\n                                                        size=\"sm\"\r\n                                                    >\r\n                                                        Final\r\n                                                    <\/Badge>\r\n                                                ) : undefined\r\n                                            }\r\n                                            onClick={() => void selectTest(test)}\r\n                                            className={cn(\r\n                                                active &&\r\n                                                    '!bg-primary-50 !text-brand !font-semibold',\r\n                                            )}\r\n                                        >\r\n                                            <span className=\"truncate\">{test.title}<\/span>\r\n                                        <\/Sidebar.Item>\r\n                                    );\r\n                                })}\r\n                            <\/Sidebar.Group>\r\n                        )}\r\n                    <\/Sidebar>\r\n                <\/div>\r\n            <\/Card>\r\n\r\n            <div className=\"grid gap-4\">\r\n                <Breadcrumbs separator={<span className=\"text-secondary-300 mx-1\">\u203a<\/span>}>\r\n                    <Breadcrumbs.Item href=\"\/dashboard\">\r\n                        <span className=\"text-sm text-secondary-500 hover:text-brand\">\r\n                            Dashboard\r\n                        <\/span>\r\n                    <\/Breadcrumbs.Item>\r\n                    <Breadcrumbs.Item>\r\n                        <span className=\"text-sm text-secondary-500\">{course.title}<\/span>\r\n                    <\/Breadcrumbs.Item>\r\n                    {selection?.kind === 'lesson' && (\r\n                        <Breadcrumbs.Item active>\r\n                            <span className=\"text-sm font-semibold text-secondary-900\">\r\n                                {selection.lesson.title}\r\n                            <\/span>\r\n                        <\/Breadcrumbs.Item>\r\n                    )}\r\n                    {selection?.kind === 'test' && (\r\n                        <Breadcrumbs.Item active>\r\n                            <span className=\"text-sm font-semibold text-secondary-900\">\r\n                                {selection.test.title}\r\n                            <\/span>\r\n                        <\/Breadcrumbs.Item>\r\n                    )}\r\n                <\/Breadcrumbs>\r\n\r\n                {!selection && (\r\n                    <Card\r\n                        variant=\"outlined\"\r\n                        padding=\"lg\"\r\n                        className=\"!rounded-xl !shadow-sm text-center\"\r\n                    >\r\n                        <Text>This course has no content yet.<\/Text>\r\n                    <\/Card>\r\n                )}\r\n\r\n                {selection?.kind === 'lesson' && (\r\n                    <>\r\n                        <LessonView\r\n                            lesson={selection.lesson}\r\n                            completed={completedLessonIds.has(selection.lesson.id)}\r\n                            onMarkComplete={onMarkLessonComplete}\r\n                            onNext={() => {\r\n                                const next = lessons[currentLessonIdx + 1];\r\n                                if (next) selectLesson(next);\r\n                                else if (allTests.length > 0) void selectTest(allTests[0]);\r\n                            }}\r\n                        \/>\r\n                        <LessonFooter\r\n                            prevLabel={lessons[currentLessonIdx - 1]?.title}\r\n                            nextLabel={\r\n                                lessons[currentLessonIdx + 1]?.title ??\r\n                                allTests[0]?.title\r\n                            }\r\n                            onPrev={\r\n                                lessons[currentLessonIdx - 1]\r\n                                    ? () => selectLesson(lessons[currentLessonIdx - 1])\r\n                                    : undefined\r\n                            }\r\n                            onNext={\r\n                                lessons[currentLessonIdx + 1]\r\n                                    ? () => selectLesson(lessons[currentLessonIdx + 1])\r\n                                    : allTests[0]\r\n                                    ? () => void selectTest(allTests[0])\r\n                                    : undefined\r\n                            }\r\n                        \/>\r\n                    <\/>\r\n                )}\r\n\r\n                {selection?.kind === 'test' && selection.attempt && (\r\n                    <TestRunner\r\n                        test={selection.test}\r\n                        onSubmit={(answers) => onSubmitAttempt(selection.attempt!, answers)}\r\n                    \/>\r\n                )}\r\n\r\n                {selection?.kind === 'test' && !selection.attempt && (\r\n                    <Card\r\n                        variant=\"outlined\"\r\n                        padding=\"lg\"\r\n                        className=\"!rounded-xl !shadow-sm\"\r\n                    >\r\n                        <div className=\"grid gap-3 text-center\">\r\n                            <Heading as=\"h3\" size=\"lg\" weight=\"bold\">\r\n                                Ready to begin?\r\n                            <\/Heading>\r\n                            <Text color=\"muted\">\r\n                                Once you start, your attempt will be recorded.\r\n                            <\/Text>\r\n                            <div className=\"flex justify-center\">\r\n                                <Button\r\n                                    onClick={() => void selectTest(selection.test)}\r\n                                    className=\"!bg-brand hover:!bg-primary-600 !text-white !font-semibold !px-6 !py-2.5 !rounded-md !shadow-sm\"\r\n                                >\r\n                                    Start test\r\n                                <\/Button>\r\n                            <\/div>\r\n                        <\/div>\r\n                    <\/Card>\r\n                )}\r\n            <\/div>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction LessonNumber({\r\n    n,\r\n    done,\r\n    active,\r\n}: {\r\n    n: number;\r\n    done: boolean;\r\n    active: boolean;\r\n}) {\r\n    return (\r\n        <span\r\n            className={cn(\r\n                'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold',\r\n                done\r\n                    ? 'bg-emerald-100 text-emerald-700'\r\n                    : active\r\n                    ? 'bg-brand text-white'\r\n                    : 'bg-secondary-100 text-secondary-700',\r\n            )}\r\n        >\r\n            {n}\r\n        <\/span>\r\n    );\r\n}\r\n\r\nfunction TestIcon() {\r\n    return (\r\n        <svg\r\n            className=\"h-4 w-4 shrink-0 text-secondary-500\"\r\n            fill=\"none\"\r\n            viewBox=\"0 0 24 24\"\r\n            stroke=\"currentColor\"\r\n            strokeWidth={2}\r\n        >\r\n            <path\r\n                strokeLinecap=\"round\"\r\n                strokeLinejoin=\"round\"\r\n                d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"\r\n            \/>\r\n        <\/svg>\r\n    );\r\n}\r\n\r\nfunction LessonFooter({\r\n    prevLabel,\r\n    nextLabel,\r\n    onPrev,\r\n    onNext,\r\n}: {\r\n    prevLabel?: string;\r\n    nextLabel?: string;\r\n    onPrev?: () => void;\r\n    onNext?: () => void;\r\n}) {\r\n    return (\r\n        <div className=\"flex items-center justify-between gap-3\">\r\n            {onPrev ? (\r\n                <Button\r\n                    variant=\"ghost\"\r\n                    onClick={onPrev}\r\n                    className=\"hover:!text-brand\"\r\n                >\r\n                    \u2190 <span className=\"hidden sm:inline ml-1\">{prevLabel ?? 'Previous'}<\/span>\r\n                    <span className=\"sm:hidden ml-1\">Previous<\/span>\r\n                <\/Button>\r\n            ) : (\r\n                <span \/>\r\n            )}\r\n            {onNext && (\r\n                <Button\r\n                    onClick={onNext}\r\n                    className=\"!bg-brand hover:!bg-primary-600 !text-white !font-semibold !px-5 !py-2.5 !rounded-md !shadow-sm\"\r\n                >\r\n                    <span className=\"hidden sm:inline\">Next: {nextLabel ?? 'Continue'}<\/span>\r\n                    <span className=\"sm:hidden\">Next<\/span> \u2192\r\n                <\/Button>\r\n            )}\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction collectTests(course: Course): Test[] {\r\n    return [\r\n        ...(course.tests ?? []),\r\n        ...(course.modules ?? []).flatMap((m) => m.tests ?? []),\r\n        ...(course.lessons ?? []).flatMap((l) => l.tests ?? []),\r\n    ];\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/course-player\/CoursePlayer.tsx"}]}