"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronLeft, ChevronRight, Crown, ListTree, Lock, PartyPopper, RotateCcw, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Curriculum } from "./curriculum";
import { allLessons, DEFAULT_COMPLETED, DEFAULT_COURSE, DEFAULT_NOTES, DEFAULT_QUESTIONS, formatTime, lockedIds } from "./data";
import { LessonTabs } from "./lesson-tabs";
import { usePlayer, VideoPlayer } from "./player";
import { Quiz, type QuizResult } from "./quiz";
import type { Course, Lesson, Note, Question, Resource } from "./types";
import { Celebration, focusRing, ProgressRing } from "./ui";
export type { Course, Lesson, Note, Question, Resource, Section } from "./types";
export type { QuizResult } from "./quiz";
export interface CoursePlayerAppProps {
course?: Course;
/** Lesson ids already completed. */
initialCompleted?: string[];
initialLessonId?: string;
initialNotes?: Note[];
initialQuestions?: Question[];
/** Display name for new notes and questions. */
learner?: string;
quizPassMark?: number;
onLessonChange?: (lesson: Lesson) => void;
onComplete?: (lessonId: string, completed: string[]) => void;
onNotesChange?: (notes: Note[]) => void;
onQuizFinish?: (r: QuizResult) => void;
onAskQuestion?: (q: Question) => void;
onResourceOpen?: (r: Resource) => void;
onUpgrade?: () => void;
className?: string;
}
type Toast = { id: number; text: string; tone?: "success" };
let seq = 0;
function BrandMark() {
return (
<svg viewBox="0 0 32 32" className="size-8 shrink-0" aria-hidden>
<rect width="32" height="32" rx="9" className="fill-primary" />
<path d="M10 10.5v11l11-5.5z" className="fill-primary-foreground" />
</svg>
);
}
export function CoursePlayerApp({
course = DEFAULT_COURSE,
initialCompleted = DEFAULT_COMPLETED,
initialLessonId,
initialNotes = DEFAULT_NOTES,
initialQuestions = DEFAULT_QUESTIONS,
learner = "Alex Rivera",
quizPassMark = 0.8,
onLessonChange,
onComplete,
onNotesChange,
onQuizFinish,
onAskQuestion,
onResourceOpen,
onUpgrade,
className,
}: CoursePlayerAppProps) {
const lessons = React.useMemo(() => allLessons(course), [course]);
const [completed, setCompleted] = React.useState<Set<string>>(() => new Set(initialCompleted));
const locked = React.useMemo(() => lockedIds(course, completed), [course, completed]);
const [currentId, setCurrentId] = React.useState(() => initialLessonId ?? lessons.find((l) => !initialCompleted.includes(l.id) && !l.premium)?.id ?? lessons[0].id);
const [notes, setNotes] = React.useState(initialNotes);
const [questions, setQuestions] = React.useState(initialQuestions);
const [drawer, setDrawer] = React.useState(false);
const [burst, setBurst] = React.useState(0);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const mainRef = React.useRef<HTMLElement>(null);
const reduce = useReducedMotion();
const lesson = lessons.find((l) => l.id === currentId) ?? lessons[0];
const idx = lessons.indexOf(lesson);
const section = course.sections.find((s) => s.lessons.includes(lesson));
const sIdx = section ? course.sections.indexOf(section) : 0;
const counted = lessons.filter((l) => !l.premium);
const doneCount = counted.filter((l) => completed.has(l.id)).length;
const pct = counted.length ? doneCount / counted.length : 0;
const nextLesson = lessons.slice(idx + 1).find((l) => !l.premium && !lockedIds(course, new Set([...completed, lesson.id])).has(l.id));
const prevLesson = [...lessons.slice(0, idx)].reverse().find((l) => !locked.has(l.id));
const toast = React.useCallback((text: string, tone?: Toast["tone"]) => {
const id = ++seq;
setToasts((t) => [...t.slice(-1), { id, text, tone }]);
window.setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3600);
}, []);
const go = (l: Lesson) => {
setCurrentId(l.id);
setDrawer(false);
mainRef.current?.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
onLessonChange?.(l);
};
const complete = (advance: boolean) => {
const already = completed.has(lesson.id);
const next = new Set(completed).add(lesson.id);
if (!already) {
setCompleted(next);
onComplete?.(lesson.id, [...next]);
setBurst((b) => b + 1);
const doneNow = counted.filter((l) => next.has(l.id)).length;
const wasGated = locked.size;
const nowGated = lockedIds(course, next).size;
toast(doneNow === counted.length ? "Course complete — congratulations!" : nowGated < wasGated ? `Capstone unlocked! ${doneNow} of ${counted.length} lessons done` : `Lesson complete · ${doneNow} of ${counted.length}`, "success");
}
if (advance && nextLesson) window.setTimeout(() => go(nextLesson), already ? 0 : 900);
};
const uncomplete = () => {
const next = new Set(completed);
next.delete(lesson.id);
setCompleted(next);
onComplete?.(lesson.id, [...next]);
};
const updateNotes = (fn: (n: Note[]) => Note[]) => {
const out = fn(notes);
setNotes(out);
onNotesChange?.(out);
};
React.useEffect(() => {
if (!drawer) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setDrawer(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawer]);
const isLocked = locked.has(lesson.id);
const isDone = completed.has(lesson.id);
const curriculum = (cls: string) => <Curriculum course={course} currentId={lesson.id} completed={completed} locked={locked} onSelect={go} className={cls} />;
return (
<div className={cn("relative flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-3 sm:px-5">
<button type="button" onClick={() => setDrawer(true)} aria-label="Open course content" className={cn("grid size-9 place-items-center rounded-lg hover:bg-accent lg:hidden", focusRing)}>
<ListTree className="size-[18px]" />
</button>
<BrandMark />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{course.title}</div>
<div className="hidden truncate text-xs text-muted-foreground sm:block">{course.subtitle}</div>
</div>
<div className="flex items-center gap-2.5 rounded-full border py-1 pl-1 pr-3">
<ProgressRing value={pct} size={30} stroke={3.5} tone={pct === 1 ? "success" : "primary"} />
<div className="text-xs leading-tight">
<div className="font-semibold tabular-nums">{Math.round(pct * 100)}%</div>
<div className="hidden text-muted-foreground sm:block">your progress</div>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1">
<aside className="hidden w-80 shrink-0 border-r bg-muted/20 lg:flex">{curriculum("w-full")}</aside>
<AnimatePresence>
{drawer && (
<>
<motion.div className="absolute inset-0 z-40 bg-black/45 backdrop-blur-[2px] lg:hidden" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setDrawer(false)} aria-hidden />
<motion.div
role="dialog"
aria-modal="true"
aria-label="Course content"
className="absolute inset-y-0 left-0 z-50 flex w-[88%] max-w-sm flex-col bg-background shadow-2xl lg:hidden"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 380, damping: 38 }}
>
<div className="flex h-12 items-center justify-between border-b px-3">
<span className="text-sm font-semibold">Course content</span>
<button type="button" autoFocus onClick={() => setDrawer(false)} aria-label="Close course content" className={cn("grid size-8 place-items-center rounded-lg hover:bg-accent", focusRing)}>
<X className="size-4" />
</button>
</div>
{curriculum("min-h-0 flex-1")}
</motion.div>
</>
)}
</AnimatePresence>
<main ref={mainRef} className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<LessonView
key={lesson.id}
course={course}
lesson={lesson}
sectionLabel={`Section ${sIdx + 1} · ${section?.title ?? ""}`}
lessonNo={(section?.lessons.indexOf(lesson) ?? 0) + 1}
lessonCount={section?.lessons.length ?? 0}
isLocked={isLocked}
isDone={isDone}
nextLesson={nextLesson}
prevLesson={prevLesson}
gatedBy={course.sections[course.sections.length - 2]?.title}
notes={notes}
questions={questions}
learner={learner}
passMark={quizPassMark}
onGo={go}
onComplete={complete}
onUncomplete={uncomplete}
onUpgrade={onUpgrade}
onAddNote={(text, t) => {
updateNotes((n) => [...n, { id: `note-${++seq}`, lessonId: lesson.id, t, text }]);
toast(`Note saved at ${formatTime(t)}`);
}}
onEditNote={(id, text) => updateNotes((n) => n.map((x) => (x.id === id ? { ...x, text } : x)))}
onDeleteNote={(id) => updateNotes((n) => n.filter((x) => x.id !== id))}
onAsk={(title, body) => {
const q: Question = { id: `qa-${++seq}`, lessonId: lesson.id, author: learner, title, body, votes: 1, voted: true, ago: "just now", answers: [] };
setQuestions((qs) => [q, ...qs]);
onAskQuestion?.(q);
}}
onVote={(id) => setQuestions((qs) => qs.map((q) => (q.id === id ? { ...q, voted: !q.voted, votes: q.votes + (q.voted ? -1 : 1) } : q)))}
onQuizFinish={(r) => {
onQuizFinish?.(r);
if (r.passed) complete(false);
}}
onResource={onResourceOpen}
/>
</main>
</div>
<Celebration burstKey={burst} />
<div className="pointer-events-none absolute inset-x-0 bottom-5 z-[90] flex flex-col items-center gap-2 px-4" aria-live="polite">
<AnimatePresence>
{toasts.map((t) => (
<motion.div key={t.id} layout initial={{ opacity: 0, y: 16, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8, scale: 0.95 }} className="flex max-w-full items-center gap-2.5 rounded-2xl bg-foreground px-4 py-2.5 text-sm font-medium text-background shadow-xl">
{t.tone === "success" ? <PartyPopper className="size-4 shrink-0 text-amber-400" aria-hidden /> : <Check className="size-4 shrink-0" aria-hidden />}
<span className="truncate">{t.text}</span>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
function LessonView({
course,
lesson,
sectionLabel,
lessonNo,
lessonCount,
isLocked,
isDone,
nextLesson,
prevLesson,
gatedBy,
notes,
questions,
learner,
passMark,
onGo,
onComplete,
onUncomplete,
onUpgrade,
onAddNote,
onEditNote,
onDeleteNote,
onAsk,
onVote,
onQuizFinish,
onResource,
}: {
course: Course;
lesson: Lesson;
sectionLabel: string;
lessonNo: number;
lessonCount: number;
isLocked: boolean;
isDone: boolean;
nextLesson?: Lesson;
prevLesson?: Lesson;
gatedBy?: string;
notes: Note[];
questions: Question[];
learner: string;
passMark: number;
onGo: (l: Lesson) => void;
onComplete: (advance: boolean) => void;
onUncomplete: () => void;
onUpgrade?: () => void;
onAddNote: (text: string, t: number) => void;
onEditNote: (id: string, text: string) => void;
onDeleteNote: (id: string) => void;
onAsk: (title: string, body: string) => void;
onVote: (id: string) => void;
onQuizFinish: (r: QuizResult) => void;
onResource?: (r: Resource) => void;
}) {
const [state, api] = usePlayer(lesson.duration);
const reduce = useReducedMotion();
const playerRef = React.useRef<HTMLDivElement>(null);
const seek = React.useCallback(
(t: number) => {
api.seek(t);
playerRef.current?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "nearest" });
playerRef.current?.querySelector<HTMLElement>("[role=region]")?.focus({ preventScroll: true });
},
[api, reduce],
);
return (
<motion.div initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }} className="mx-auto max-w-[920px] px-3 pb-10 pt-3 sm:px-6 sm:pt-5">
<div ref={playerRef}>
{isLocked ? (
<div className="relative grid min-h-[280px] w-full place-items-center sm:aspect-video sm:max-h-[430px] overflow-hidden rounded-2xl border bg-gradient-to-br from-muted via-card to-muted p-6 text-center">
<div className="absolute inset-0 opacity-40 [background:radial-gradient(circle_at_30%_20%,var(--primary)_0,transparent_45%)]" aria-hidden />
<div className="relative">
<span className="mx-auto grid size-14 place-items-center rounded-2xl bg-background shadow-md">{lesson.premium ? <Crown className="size-6 text-amber-500" aria-hidden /> : <Lock className="size-6" aria-hidden />}</span>
<h2 className="mt-4 text-lg font-semibold">{lesson.premium ? "This is a Pro lesson" : "This lesson is locked"}</h2>
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{lesson.premium ? "Upgrade to Pro to unlock masterclasses, source files and certificates." : `Finish every lesson up to and including “${gatedBy}” to unlock the capstone.`}</p>
{lesson.premium ? (
<button type="button" onClick={onUpgrade} className={cn("mt-4 inline-flex h-10 items-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 px-4 text-sm font-semibold text-white shadow-sm hover:brightness-110", focusRing)}>
<Crown className="size-4" aria-hidden /> Upgrade to Pro
</button>
) : (
prevLesson && (
<button type="button" onClick={() => onGo(prevLesson)} className={cn("mt-4 inline-flex h-10 items-center gap-2 rounded-xl border bg-background px-4 text-sm font-semibold hover:bg-accent", focusRing)}>
Go to “{prevLesson.title}”
</button>
)
)}
</div>
</div>
) : lesson.kind === "quiz" ? (
<Quiz lesson={lesson} passMark={passMark} completed={isDone} onFinish={onQuizFinish} onContinue={() => onComplete(true)} />
) : (
<VideoPlayer lesson={lesson} state={state} api={api} nextTitle={nextLesson?.title} onNext={() => onComplete(true)} className="aspect-[4/3] max-h-[430px] w-full sm:aspect-video" />
)}
</div>
<div className="mt-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
{sectionLabel} · Lesson {lessonNo} of {lessonCount}
</div>
<h1 className="mt-0.5 text-lg font-semibold tracking-tight sm:text-xl">{lesson.title}</h1>
</div>
<div className="flex items-center gap-1.5">
<button type="button" onClick={() => prevLesson && onGo(prevLesson)} disabled={!prevLesson} aria-label={prevLesson ? `Previous: ${prevLesson.title}` : "No previous lesson"} className={cn("grid size-10 place-items-center rounded-xl border hover:bg-accent disabled:opacity-40", focusRing)}>
<ChevronLeft className="size-4" />
</button>
{!isLocked &&
(isDone ? (
<button type="button" onClick={onUncomplete} className={cn("group inline-flex h-10 items-center gap-2 rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3.5 text-sm font-semibold text-emerald-700 dark:text-emerald-300", focusRing)} aria-label="Completed. Mark as not complete">
<Check className="size-4 group-hover:hidden" aria-hidden />
<RotateCcw className="hidden size-4 group-hover:block" aria-hidden />
<span className="group-hover:hidden">Completed</span>
<span className="hidden group-hover:inline">Undo</span>
</button>
) : lesson.kind === "video" ? (
<button type="button" onClick={() => onComplete(true)} className={cn("inline-flex h-10 items-center gap-2 rounded-xl bg-primary px-3.5 text-sm font-semibold text-primary-foreground shadow-sm hover:opacity-90", focusRing)}>
<Check className="size-4" aria-hidden /> Mark complete
</button>
) : null)}
<button type="button" onClick={() => nextLesson && onGo(nextLesson)} disabled={!nextLesson} aria-label={nextLesson ? `Next: ${nextLesson.title}` : "No next lesson"} className={cn("grid size-10 place-items-center rounded-xl border hover:bg-accent disabled:opacity-40", focusRing)}>
<ChevronRight className="size-4" />
</button>
</div>
</div>
<div className="mt-3">
<LessonTabs
course={course}
lesson={lesson}
time={Math.floor(state.time)}
notes={notes}
questions={questions}
me={learner}
onAddNote={onAddNote}
onEditNote={onEditNote}
onDeleteNote={onDeleteNote}
onSeek={seek}
onAsk={onAsk}
onVote={onVote}
onResource={onResource}
/>
</div>
</motion.div>
);
}
export default CoursePlayerApp;