Fazekit

Code

"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;

More in Media

View all →