This commit is contained in:
rgrgogu
2026-05-20 13:25:03 +08:00
parent 2b1955608d
commit 95242709b0
71 changed files with 5535 additions and 1035 deletions
@@ -0,0 +1,241 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, House, Pencil, Clock, BookOpen, Layers,
BadgeCheck, Tag, Star, Lock, ListChecks,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
</div>
);
}
function SectionCard({ icon: Icon, title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<Separator />
{children}
</div>
);
}
const LEVEL_BADGE = {
beginner: "secondary",
intermediate: "outline",
advanced: "destructive",
};
const SUBSCRIPTION_BADGE = {
free: "secondary",
premium: "default",
};
function LoadingSkeleton() {
return (
<div className="space-y-5">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40" />
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewCourse() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses();
useEffect(() => {
fetchCourse(courseId);
}, [courseId]);
return (
<section className="bg-muted/60 min-h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
{/* ── Header ── */}
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Course Details</h1>
<p className="text-sm text-muted-foreground">View course information.</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
</div>
{loading && !course ? (
<LoadingSkeleton />
) : !course ? (
<div className="text-sm text-muted-foreground">Course not found.</div>
) : (
<div className="space-y-5">
{/* ── Basic Info ── */}
<SectionCard icon={BookOpen} title="Basic Information">
<div className="space-y-4">
<InfoRow label="Title">{course.title}</InfoRow>
{course.description && (
<InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{course.description}
</span>
</InfoRow>
)}
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Course Code">
{course.course_code ?? <span className="text-muted-foreground italic text-sm">—</span>}
</InfoRow>
<InfoRow label="Order Index">{course.order_index ?? 0}</InfoRow>
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard icon={Tag} title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Level">
{course.level ? (
<Badge variant={LEVEL_BADGE[course.level] ?? "outline"} className="capitalize mt-0.5">
{course.level}
</Badge>
) : null}
</InfoRow>
<InfoRow label="Subscription">
<Badge variant={SUBSCRIPTION_BADGE[course.subscription] ?? "outline"} className="capitalize mt-0.5">
{course.subscription ?? "free"}
</Badge>
</InfoRow>
</div>
</SectionCard>
{/* ── Duration & Stats ── */}
<SectionCard icon={Clock} title="Duration & Stats">
<div className="grid grid-cols-3 gap-4">
<InfoRow label="Duration">
{course.duration_formatted ?? course.duration_seconds
? `${course.duration_seconds}s`
: "—"}
</InfoRow>
<InfoRow label="Units">
<div className="flex items-center gap-1.5 mt-0.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
{course.unitCount ?? course.units?.length ?? 0}
</div>
</InfoRow>
<InfoRow label="Lessons">
<div className="flex items-center gap-1.5 mt-0.5">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{course.lessonCount ?? 0}
</div>
</InfoRow>
</div>
</SectionCard>
{/* ── Objectives ── */}
{course.objectives?.length > 0 && (
<SectionCard icon={ListChecks} title="Learning Objectives">
<ul className="space-y-2">
{course.objectives.map((obj, i) => (
<li key={obj.objective_id ?? i} className="flex items-start gap-2 text-sm">
<BadgeCheck className="h-4 w-4 text-primary mt-0.5 shrink-0" />
{obj.text}
</li>
))}
</ul>
</SectionCard>
)}
{/* ── Prerequisites ── */}
{course.prerequisites?.length > 0 && (
<SectionCard icon={Star} title="Prerequisites">
<ul className="space-y-2">
{course.prerequisites.map((p, i) => (
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
<span className="text-muted-foreground">ID: {p.ref_id}</span>
</li>
))}
</ul>
</SectionCard>
)}
{/* ── Assessment ── */}
{course.assessment && (
<SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
<InfoRow label="Required">
<Badge variant={course.assessment.is_required ? "default" : "secondary"}>
{course.assessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{course.assessment.time_limit_minutes
? `${course.assessment.time_limit_minutes} mins`
: "No limit"}
</InfoRow>
</div>
</SectionCard>
)}
{/* ── Audit ── */}
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At">
{course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"}
</InfoRow>
<InfoRow label="Updated At">
{course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
)}
</div>
</div>
</section>
);
}