mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,648 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import LessonBlock from "../components/LessonBlock.jsx";
|
||||
import QuizBlock from "../components/blocks/QuizBlock.jsx";
|
||||
import CourseCompleteBlock from "../components/blocks/CourseCompleteBlock.jsx";
|
||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ─── Assessment Gate ───────────────────────────────────────────────────────
|
||||
|
||||
const QUIZ_MODAL_THRESHOLD = 1;
|
||||
|
||||
const QuizGateList = ({ requiredQuizzes, units, onQuizClick, onItemClick }) => (
|
||||
<div className="space-y-2">
|
||||
{requiredQuizzes.map((q) => (
|
||||
<div
|
||||
key={q.quizId}
|
||||
onClick={q.has_passed ? undefined : () => {
|
||||
onQuizClick({ unit: units.find((u) => u.unit_id === q.unitId), quiz: { quiz_id: q.quizId } });
|
||||
onItemClick?.();
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
q.has_passed
|
||||
? "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
|
||||
: "border-border hover:bg-muted cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{q.has_passed
|
||||
? <CheckCircle2 className="size-5 text-emerald-500 shrink-0" />
|
||||
: <Circle className="size-5 text-muted-foreground shrink-0" />
|
||||
}
|
||||
<span className={`text-sm font-medium flex-1 ${q.has_passed ? "text-emerald-700 dark:text-emerald-400" : ""}`}>
|
||||
{q.title}
|
||||
</span>
|
||||
{!q.has_passed && <ArrowRight className="size-4 text-muted-foreground shrink-0" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AssessmentGate = ({ requiredQuizzes, units, onQuizClick }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const passed = requiredQuizzes.filter((q) => q.has_passed).length;
|
||||
const useModal = requiredQuizzes.length > QUIZ_MODAL_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center mb-4">
|
||||
<Lock className="size-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2">Complete All Unit Quizzes First</h2>
|
||||
<p className="text-muted-foreground mb-4 max-w-sm text-sm">
|
||||
You must pass every required unit quiz before you can attempt the Final Assessment.
|
||||
</p>
|
||||
|
||||
{useModal ? (
|
||||
<>
|
||||
<p className="text-sm mb-4">
|
||||
{passed} of {requiredQuizzes.length} quizzes passed
|
||||
</p>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<ClipboardList className="size-4" />
|
||||
View Required Quizzes
|
||||
</Button>
|
||||
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Required Quizzes"
|
||||
description={`Complete all ${requiredQuizzes.length} quizzes before attempting the Final Assessment.`}
|
||||
hideDrawerClose={false}
|
||||
>
|
||||
<ScrollArea className="max-h-72 pr-1">
|
||||
<QuizGateList
|
||||
requiredQuizzes={requiredQuizzes}
|
||||
units={units}
|
||||
onQuizClick={onQuizClick}
|
||||
onItemClick={() => setOpen(false)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full max-w-sm text-left">
|
||||
<QuizGateList
|
||||
requiredQuizzes={requiredQuizzes}
|
||||
units={units}
|
||||
onQuizClick={onQuizClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Sidebar ───────────────────────────────────────────────────────────────
|
||||
|
||||
const SidebarContent = ({
|
||||
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
|
||||
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
|
||||
isCompleted, selectedCompletion, onCompletionClick,
|
||||
getLessonCompleted, getUnitCompleted,
|
||||
loading,
|
||||
}) => (
|
||||
<ScrollArea className="h-full p-3 md:p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2">
|
||||
Course Content
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-2 pt-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Accordion type="multiple" defaultValue={units[0] ? [`unit-${units[0].unit_id}`] : []} className="space-y-1">
|
||||
{units.map((unit, index) => (
|
||||
<AccordionItem key={unit.unit_id} value={`unit-${unit.unit_id}`} className="border-none">
|
||||
<AccordionTrigger className="px-3 py-2 text-sm font-semibold rounded-lg hover:bg-muted-foreground/10 hover:no-underline">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{getUnitCompleted?.(unit)
|
||||
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
|
||||
: <Circle className="size-4 text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="truncate">Unit {index + 1}: {unit.title}</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-1">
|
||||
<ul className="space-y-0.5">
|
||||
{(unit.lessons ?? []).map((lesson) => {
|
||||
const lessonDone = getLessonCompleted?.(lesson);
|
||||
return (
|
||||
<li
|
||||
key={lesson.lesson_id}
|
||||
onClick={() => onLessonClick({ unit, lesson })}
|
||||
className={`flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 hover:text-foreground cursor-pointer transition-colors ${selectedLessonId === lesson.lesson_id
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{lessonDone
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: <Circle className="size-3.5 text-muted-foreground/30 shrink-0" />
|
||||
}
|
||||
<span className="truncate">{lesson.title}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{unit.quiz && (
|
||||
<li
|
||||
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 cursor-pointer transition-colors ${
|
||||
selectedQuizId === unit.quiz.quiz_id
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: unit.quiz.has_passed
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{unit.quiz.has_passed
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: <ClipboardList className="size-3.5 shrink-0" />
|
||||
}
|
||||
{unit.quiz.title || "Quiz"}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{courseAssessment && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div
|
||||
onClick={onAssessmentClick}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold cursor-pointer transition-colors ${selectedAssessment
|
||||
? "bg-muted-foreground/10 text-foreground"
|
||||
: "hover:bg-muted-foreground/10 text-foreground"
|
||||
}`}
|
||||
>
|
||||
{assessmentLocked
|
||||
? <Lock className="size-4 shrink-0 text-amber-500" />
|
||||
: <GraduationCap className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
{courseAssessment.title || "Course Assessment"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="mt-1">
|
||||
<div
|
||||
onClick={onCompletionClick}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold cursor-pointer transition-colors ${selectedCompletion
|
||||
? "bg-muted-foreground/10 text-foreground"
|
||||
: "hover:bg-muted-foreground/10 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Trophy className="size-4 shrink-0 text-amber-500" />
|
||||
Course Complete
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ScrollBar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// ─── UnitList ─────────────────────────────────────────────────────────────
|
||||
|
||||
const UnitList = () => {
|
||||
const { id: courseId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
course, courseLoading, getCourse,
|
||||
lesson, lessonLoading, getLesson, resetLesson,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
|
||||
assessment, assessmentLoading, getCourseAssessment, resetAssessment, submitCourseAssessment,
|
||||
} = useClientCourses();
|
||||
|
||||
const {
|
||||
fetchCourseProgress,
|
||||
upsertLessonProgress,
|
||||
isCompleted: isProgressCompleted,
|
||||
isRead: isProgressRead,
|
||||
resetProgress,
|
||||
} = useCourseReadingProgress();
|
||||
|
||||
// Tracks which lessons have been marked completed this session to avoid duplicate calls
|
||||
const completedSessionRef = useRef(new Set());
|
||||
|
||||
// ── Local UI state ──────────────────────────────────────────────────────
|
||||
const [selectedLessonId, setSelectedLessonId] = useState(null);
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null);
|
||||
const [selectedAssessment, setSelectedAssessment] = useState(false);
|
||||
const [selectedCompletion, setSelectedCompletion] = useState(false);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
|
||||
|
||||
const resetCompletion = () => setSelectedCompletion(false);
|
||||
|
||||
// ── Quiz gate — all required unit quizzes must be passed before assessment ─
|
||||
const requiredQuizzes = (course?.units ?? [])
|
||||
.filter((u) => u.quiz?.is_required)
|
||||
.map((u) => ({
|
||||
unit: u,
|
||||
unitId: u.unit_id,
|
||||
quizId: u.quiz.quiz_id,
|
||||
title: u.quiz.title || "Quiz",
|
||||
has_passed: u.quiz.has_passed ?? false,
|
||||
}));
|
||||
const allRequiredQuizzesPassed =
|
||||
requiredQuizzes.length === 0 || requiredQuizzes.every((q) => q.has_passed);
|
||||
|
||||
// ── Flatten all content (lessons + quiz + final assessment) ──────────────
|
||||
const allContent = (course?.units ?? []).flatMap((u) => [
|
||||
...(u.lessons ?? []).map((l) => ({ type: "lesson", unit: u, lesson: l })),
|
||||
...(u.quiz ? [{ type: "quiz", unit: u, quiz: u.quiz }] : []),
|
||||
]);
|
||||
if (course?.assessment && allRequiredQuizzesPassed) {
|
||||
allContent.push({ type: "assessment", assessment: course.assessment });
|
||||
}
|
||||
|
||||
// ── Scroll progress ────────────────────────────────────────────────────
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setScrollProgress(0);
|
||||
window.scrollTo(0, 0);
|
||||
}, [selectedLessonId, selectedQuizId, selectedAssessment, selectedCompletion]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (scrollHeight <= 0) { setScrollProgress(100); return; }
|
||||
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// ── Mark lesson completed when user scrolls to the bottom ─────────────
|
||||
useEffect(() => {
|
||||
if (scrollProgress < 100 || !selectedLessonId || !selectedUnitId || !lesson?.uuid) return;
|
||||
if (completedSessionRef.current.has(selectedLessonId)) return;
|
||||
if (isProgressCompleted(lesson.uuid)) return;
|
||||
completedSessionRef.current.add(selectedLessonId);
|
||||
upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
|
||||
}, [scrollProgress]);
|
||||
|
||||
// ── Fetch course + progress on mount ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
getCourse(courseId);
|
||||
fetchCourseProgress(courseId);
|
||||
return () => {
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
resetProgress();
|
||||
completedSessionRef.current.clear();
|
||||
};
|
||||
}, [courseId]);
|
||||
|
||||
// ── Auto-load lesson once course data is available ────────────────────
|
||||
// If navigated from CourseDetails with a specific lesson, open that one;
|
||||
// otherwise fall back to the first lesson.
|
||||
useEffect(() => {
|
||||
if (!course || selectedLessonId || selectedQuizId || selectedAssessment || selectedCompletion) return;
|
||||
const { lessonId, unitId, seekFirstIncomplete } = location.state ?? {};
|
||||
|
||||
if (seekFirstIncomplete) {
|
||||
const firstIncompleteUnit = (course.units ?? []).find((u) => u.quiz && !u.quiz.has_passed);
|
||||
if (firstIncompleteUnit) {
|
||||
setSelectedQuizId(firstIncompleteUnit.quiz.quiz_id);
|
||||
setSelectedUnitId(firstIncompleteUnit.unit_id);
|
||||
getUnitQuiz(courseId, firstIncompleteUnit.unit_id);
|
||||
return;
|
||||
}
|
||||
if (course.assessment) {
|
||||
setSelectedAssessment(true);
|
||||
if (allRequiredQuizzesPassed) getCourseAssessment(courseId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (lessonId && unitId) {
|
||||
setSelectedLessonId(lessonId);
|
||||
setSelectedUnitId(unitId);
|
||||
getLesson(courseId, unitId, lessonId);
|
||||
} else {
|
||||
const firstUnit = course.units?.[0];
|
||||
const firstLesson = firstUnit?.lessons?.[0];
|
||||
if (firstUnit && firstLesson) {
|
||||
setSelectedLessonId(firstLesson.lesson_id);
|
||||
setSelectedUnitId(firstUnit.unit_id);
|
||||
getLesson(courseId, firstUnit.unit_id, firstLesson.lesson_id);
|
||||
}
|
||||
}
|
||||
}, [course]);
|
||||
|
||||
// ── Breadcrumbs ─────────────────────────────────────────────────────────
|
||||
const currentUnit = course?.units?.find((u) => u.unit_id === selectedUnitId);
|
||||
const currentLesson = lesson;
|
||||
const currentLessonStub = currentUnit?.lessons?.find((l) => l.lesson_id === selectedLessonId) ?? null;
|
||||
const pageTitle = (() => {
|
||||
if (!course) return undefined;
|
||||
if (selectedCompletion) return `${course.title} – Completed - STARR`;
|
||||
if (selectedAssessment) return `${course.title} – Assessment - STARR`;
|
||||
if (selectedQuizId && currentUnit) return `${currentUnit.title} – Quiz - STARR`;
|
||||
const lessonTitle = lesson?.title ?? currentLessonStub?.title;
|
||||
if (selectedLessonId && lessonTitle) return `${lessonTitle} - STARR`;
|
||||
return `${course.title} - STARR`;
|
||||
})();
|
||||
|
||||
const breadcrumbItemsFull = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: "Courses", to: `/course` },
|
||||
{ label: course?.title ?? "Course", to: `/course/${courseId}` },
|
||||
{
|
||||
label: selectedCompletion
|
||||
? "Course Complete"
|
||||
: selectedAssessment
|
||||
? "Course Assessment"
|
||||
: currentUnit ? `Unit ${(course?.units?.indexOf(currentUnit) ?? 0) + 1}: ${currentUnit.title}` : "Select a lesson"
|
||||
},
|
||||
];
|
||||
const breadcrumbItemsMobile = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: selectedCompletion ? "Course Complete" : selectedAssessment ? "Course Assessment" : (currentUnit?.title ?? "Select a lesson") },
|
||||
];
|
||||
|
||||
// ── Lesson click ───────────────────────────────────────────────────────
|
||||
const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => {
|
||||
if (lessonStub.lesson_id === selectedLessonId) {
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedLessonId(lessonStub.lesson_id);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
|
||||
// Fire in_progress only if not already started or completed
|
||||
if (!isProgressRead(lessonStub.uuid)) {
|
||||
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
|
||||
}
|
||||
}, [selectedLessonId, courseId, getLesson, resetQuiz, resetAssessment, isProgressRead, upsertLessonProgress]);
|
||||
|
||||
// ── Quiz click ─────────────────────────────────────────────────────────
|
||||
const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => {
|
||||
if (quizStub.quiz_id === selectedQuizId) {
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedQuizId(quizStub.quiz_id);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
await getUnitQuiz(courseId, unit.unit_id);
|
||||
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment]);
|
||||
|
||||
// ── Course assessment click ─────────────────────────────────────────────
|
||||
const handleAssessmentClick = useCallback(async () => {
|
||||
if (selectedAssessment) {
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedAssessment(true);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
setSidebarOpen(false);
|
||||
if (allRequiredQuizzesPassed) {
|
||||
await getCourseAssessment(courseId);
|
||||
}
|
||||
}, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]);
|
||||
|
||||
// ── Course complete click ───────────────────────────────────────────────
|
||||
const handleCompletionClick = useCallback(() => {
|
||||
if (selectedCompletion) {
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedCompletion(true);
|
||||
setSidebarOpen(false);
|
||||
}, [selectedCompletion, resetLesson, resetQuiz, resetAssessment]);
|
||||
|
||||
// ── Next content item (lesson, quiz, or final assessment) ─────────────
|
||||
const getNextContent = useCallback(() => {
|
||||
const idx = allContent.findIndex((item) =>
|
||||
(item.type === "lesson" && item.lesson.lesson_id === selectedLessonId) ||
|
||||
(item.type === "quiz" && item.quiz.quiz_id === selectedQuizId) ||
|
||||
(item.type === "assessment" && selectedAssessment)
|
||||
);
|
||||
return idx !== -1 && idx < allContent.length - 1 ? allContent[idx + 1] : null;
|
||||
}, [selectedLessonId, selectedQuizId, selectedAssessment, allContent]);
|
||||
|
||||
const units = course?.units ?? [];
|
||||
const nextContent = getNextContent();
|
||||
|
||||
const handleNextContentClick = () => {
|
||||
if (!nextContent) return;
|
||||
if (nextContent.type === "lesson") handleLessonClick(nextContent);
|
||||
else if (nextContent.type === "quiz") handleQuizClick(nextContent);
|
||||
else handleAssessmentClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title={pageTitle} />
|
||||
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
|
||||
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div
|
||||
onClick={handleNextContentClick}
|
||||
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm text-muted-foreground font-normal">Up next</span>
|
||||
{nextContent.type === "lesson" ? nextContent.lesson.title
|
||||
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
|
||||
: (nextContent.assessment?.title || "Course Assessment")}
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Top sticky bar ── */}
|
||||
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
|
||||
<div className="w-full flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="hidden sm:block min-w-0">
|
||||
<AppBreadcrumb items={breadcrumbItemsFull} />
|
||||
</div>
|
||||
<div className="sm:hidden min-w-0 truncate">
|
||||
<AppBreadcrumb items={breadcrumbItemsMobile} />
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="lg:hidden flex-shrink-0"
|
||||
aria-label="Open course content"
|
||||
>
|
||||
<TableOfContents className="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-80">
|
||||
<SidebarContent
|
||||
units={units}
|
||||
selectedLessonId={selectedLessonId}
|
||||
selectedQuizId={selectedQuizId}
|
||||
onLessonClick={handleLessonClick}
|
||||
onQuizClick={handleQuizClick}
|
||||
courseAssessment={course?.assessment}
|
||||
selectedAssessment={selectedAssessment}
|
||||
onAssessmentClick={handleAssessmentClick}
|
||||
assessmentLocked={!allRequiredQuizzesPassed}
|
||||
isCompleted={course?.is_completed}
|
||||
selectedCompletion={selectedCompletion}
|
||||
onCompletionClick={handleCompletionClick}
|
||||
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
|
||||
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
|
||||
loading={courseLoading}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop TOC toggle */}
|
||||
<ButtonGroup id="call-to-action" className="flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden lg:flex"
|
||||
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
|
||||
>
|
||||
<TableOfContents />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
{/* Scroll progress bar */}
|
||||
{lesson && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-border">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-150 ease-out"
|
||||
style={{ width: `${scrollProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Desktop sidebar ── */}
|
||||
{desktopSidebarOpen && (
|
||||
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
|
||||
<SidebarContent
|
||||
units={units}
|
||||
selectedLessonId={selectedLessonId}
|
||||
selectedQuizId={selectedQuizId}
|
||||
onLessonClick={handleLessonClick}
|
||||
onQuizClick={handleQuizClick}
|
||||
courseAssessment={course?.assessment}
|
||||
selectedAssessment={selectedAssessment}
|
||||
onAssessmentClick={handleAssessmentClick}
|
||||
assessmentLocked={!allRequiredQuizzesPassed}
|
||||
isCompleted={course?.is_completed}
|
||||
selectedCompletion={selectedCompletion}
|
||||
onCompletionClick={handleCompletionClick}
|
||||
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
|
||||
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
|
||||
loading={courseLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`mt-32 ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
{selectedCompletion ? (
|
||||
<CourseCompleteBlock course={course} />
|
||||
) : selectedAssessment ? (
|
||||
!allRequiredQuizzesPassed ? (
|
||||
<AssessmentGate
|
||||
requiredQuizzes={requiredQuizzes}
|
||||
units={units}
|
||||
onQuizClick={handleQuizClick}
|
||||
/>
|
||||
) : (
|
||||
<QuizBlock
|
||||
quiz={assessment ? { ...assessment, quiz_id: assessment.assessment_id } : null}
|
||||
loading={assessmentLoading}
|
||||
label="Assessment"
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers);
|
||||
await getCourse(courseId);
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getCourseAssessment(courseId)}
|
||||
/>
|
||||
)
|
||||
) : selectedQuizId ? (
|
||||
<QuizBlock
|
||||
quiz={quiz}
|
||||
loading={quizLoading}
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
|
||||
await getCourse(courseId);
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
|
||||
/>
|
||||
) : (
|
||||
<LessonBlock
|
||||
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
|
||||
loading={lessonLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnitList;
|
||||
Reference in New Issue
Block a user