Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-11 12:12:40 +08:00
parent 01a2c63b06
commit 71f758fe0b
66 changed files with 3055 additions and 939 deletions
+118 -136
View File
@@ -1,12 +1,12 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, Layers, SendHorizonal, CheckCheck, CheckCircle2, Circle,
FileQuestion, Hourglass, ClipboardList,
House, Timer, CheckCircle2, Check, ClipboardList, Hourglass,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "framer-motion";
import { useEffect } from "react";
@@ -25,87 +25,91 @@ function formatDuration(seconds = 0) {
return `${m}min`;
}
// ─── Content card (single unit — lessons + quiz, no multi-unit spine) ─────────
// ─── Lessons roadmap (single unit — lessons in order + trailing quiz row) ─────
const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
const lessons = unitDetail.lessons ?? [];
const quiz = unitDetail.quiz ?? null;
return (
<motion.div
className="rounded-xl border bg-card"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
<div className="px-4 py-4 border-b flex flex-col gap-2">
<div className="[&_svg]:size-4 text-md text-muted-foreground flex items-center gap-4">
{lessons.length > 0 && (
<div className="flex items-center gap-1.5">
<Layers /> {lessons.length} {lessons.length === 1 ? "Lesson" : "Lessons"}
</div>
)}
{unitDetail.duration_seconds > 0 && (
<div className="flex items-center gap-1.5">
<Timer /> {formatDuration(unitDetail.duration_seconds)}
</div>
)}
{quiz && (
<div className="flex items-center gap-1.5">
<FileQuestion /> Quiz
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-1.5">
{lessons.map((lesson) => (
<div
<div className="flex flex-col gap-1">
{lessons.map((lesson, index) => {
const completed = lesson.status === "completed";
const isCurrent = lesson.lesson_id === currentLessonId;
return (
<motion.div
key={lesson.lesson_id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg cursor-pointer transition-colors",
isCurrent ? "bg-blue-50 dark:bg-blue-950/40" : "hover:bg-muted/60"
)}
onClick={() => onLessonClick(lesson)}
>
<div className="flex items-start gap-3 select-none min-w-0">
{lesson.status === "completed"
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0 mt-0.5" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />
}
<div className="flex items-start gap-3 min-w-0">
<div className={cn(
"w-7 h-7 rounded-full border bg-background flex items-center justify-center text-xs font-medium shrink-0",
completed
? "border-emerald-500 text-emerald-500"
: isCurrent
? "border-blue-500 text-blue-500"
: "border-border text-muted-foreground"
)}>
{completed ? <Check className="size-3.5" /> : index + 1}
</div>
<div className="min-w-0">
<span className="text-md text-card-foreground truncate block">{lesson.title}</span>
{lesson.description && (
<span className="text-sm text-muted-foreground line-clamp-1 block">{lesson.description}</span>
)}
<span className={cn(
"block truncate",
completed || isCurrent ? "font-medium text-card-foreground" : "text-muted-foreground"
)}>
{lesson.title}
</span>
<span className="text-sm text-muted-foreground">
{formatDuration(lesson.duration_seconds) ?? "—"}
{completed && " · Completed"}
{isCurrent && !completed && " · In progress"}
</span>
</div>
</div>
{lesson.duration_seconds > 0 && (
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
{isCurrent && !completed && (
<Button
size="sm"
className="bg-blue-500 shrink-0"
onClick={(e) => { e.stopPropagation(); onContinue(lesson); }}
>
Continue
</Button>
)}
</div>
))}
</motion.div>
);
})}
{quiz && (
<div
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()}
>
<div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <ClipboardList className="size-4 text-blue-500 shrink-0" />
}
<span className="text-sm font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div>
<Badge className={cn(
"shrink-0 ml-2 text-[10px]",
quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
)}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
{quiz && (
<div
className="flex items-center justify-between py-3 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()}
>
<div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed
? <CheckCircle2 className="size-5 text-emerald-500 shrink-0" />
: <ClipboardList className="size-5 text-blue-500 shrink-0" />
}
<span className="font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div>
)}
</div>
</motion.div>
<Badge className={cn(
"shrink-0 ml-2 text-[10px]",
quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
)}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
</div>
)}
</div>
);
};
@@ -118,7 +122,6 @@ const UnitDetails = () => {
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = !!unitDetail?.is_completed;
const contentNotReady = !unitDetail?.duration_seconds;
useEffect(() => {
@@ -149,9 +152,17 @@ const UnitDetails = () => {
);
}
const lessons = unitDetail?.lessons ?? [];
const completedCount = lessons.filter((l) => l.status === "completed").length;
const currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
const handleLessonClick = (lesson) => {
navigate(`/lessons/${lesson.uuid}`);
};
const handleContinue = (lesson) => {
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleQuizClick = () => {
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
};
@@ -159,74 +170,45 @@ const UnitDetails = () => {
return (
<div>
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
<div className="my-17">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
<div className="xs:my-20 lg:my-28 lg:container lg:mx-auto xs:px-4 lg:px-4 flex flex-col gap-6 max-w-3xl">
<AppBreadcrumb items={items} />
{/* Hero */}
<div className="bg-primary dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<AppBreadcrumb
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items}
/>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="max-w-2xl xs:text-sm lg:text-lg">{unitDetail?.description ?? ""}</p>
{unitDetail?.duration_seconds > 0 && (
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
<Timer />
{formatDuration(unitDetail.duration_seconds)}
</div>
)}
<div className="w-fit">
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : (
<Button
className="w-fit bg-blue-500"
onClick={() => navigate(`/units/${uuid}/read`)}
>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Learning</>
}
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col gap-8 max-w-3xl">
<div className="space-y-4">
<div className="font-bold text-2xl">About this unit</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{unitDetail?.description ?? ""}</p>
</div>
</div>
{unitDetail && !contentNotReady && (
<div className="space-y-4">
<div className="font-bold text-2xl">Unit content</div>
<UnitContentCard
unitDetail={unitDetail}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
/>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-blue-600 dark:text-blue-400 uppercase">Unit</span>
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="text-muted-foreground lg:text-lg">{unitDetail?.description ?? ""}</p>
</div>
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground w-fit">
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
<span>{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
<span>·</span>
<span className="flex items-center gap-1"><Timer /> {formatDuration(unitDetail.duration_seconds) ?? "—"} total</span>
<span>·</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">{completedCount} of {lessons.length} complete</span>
</div>
<Progress value={progressPct} />
</div>
<div className="flex flex-col gap-3">
<h2 className="font-bold text-xl">Lessons in this unit</h2>
<LessonsRoadmap
unitDetail={unitDetail}
currentLessonId={currentLesson?.lesson_id}
onLessonClick={handleLessonClick}
onContinue={handleContinue}
onQuizClick={handleQuizClick}
/>
</div>
</>
)}
</div>
</div>
);