mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
428 lines
17 KiB
React
428 lines
17 KiB
React
import { useEffect, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
ArrowLeft, Pencil, Clock, BookOpen, Layers,
|
|
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
|
|
Trophy, Users, Award,
|
|
} from "lucide-react";
|
|
|
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
|
import { PageMeta } from "@/contexts/MetadataContext";
|
|
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
|
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
|
import api from "@/utils/api.util";
|
|
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",
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─── Tab: Course Details ───────────────────────────────────────────────────────
|
|
|
|
function CourseDetailsTab({ course, loading, instructors, achievementKeys, achievementRegistry, badgeImageUrl }) {
|
|
const { fmtDateTime } = useDateFormat();
|
|
|
|
if (loading && !course) return <LoadingSkeleton />;
|
|
if (!course) return <div className="text-sm text-muted-foreground">Course not found.</div>;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<SectionCard icon={Users} title="Course Instructors">
|
|
{instructors.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic">No instructors assigned.</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{instructors.map((inst, i) => {
|
|
const fullName = inst.user?.personal_info?.name?.full_name ?? null;
|
|
const email = inst.user?.email ?? null;
|
|
return (
|
|
<li key={inst.id ?? i} className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
|
|
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium leading-tight">{inst.display_name}</p>
|
|
{(fullName || email) && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{fullName ?? email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Badge variant="outline" className="ml-auto text-[10px] shrink-0">
|
|
#{i + 1}
|
|
</Badge>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</SectionCard>
|
|
|
|
<SectionCard icon={Award} title="Rewards">
|
|
<div className="space-y-5">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">Completion Badge</p>
|
|
<div className="flex items-start gap-4">
|
|
<CourseBadge
|
|
title={course.title}
|
|
level={course.level}
|
|
color={course.badge_color ?? "purple"}
|
|
imageUrl={badgeImageUrl}
|
|
/>
|
|
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground pt-1">
|
|
<div><span className="font-medium text-foreground">Label:</span> Course Completion</div>
|
|
<div><span className="font-medium text-foreground">Trigger:</span> Pass course assessment</div>
|
|
<div><span className="font-medium text-foreground">Type:</span> Milestone achievement</div>
|
|
<div><span className="font-medium text-foreground">Color:</span> <span className="capitalize">{course.badge_color ?? "purple"}</span></div>
|
|
<Badge className="self-start mt-1 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
|
|
<BadgeCheck className="size-3" /> Mandatory
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">
|
|
Achievements
|
|
<span className="ml-2 normal-case font-normal">({achievementKeys.length}/3)</span>
|
|
</p>
|
|
{achievementKeys.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic">No achievements assigned.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{achievementKeys.map((key) => {
|
|
const ach = achievementRegistry.find((a) => a.key === key);
|
|
return (
|
|
<li key={key} className="flex items-start gap-2.5 text-sm">
|
|
<div className="mt-0.5 shrink-0">
|
|
{ach?.type === "badge"
|
|
? <Trophy className="h-4 w-4 text-amber-500" />
|
|
: <BadgeCheck className="h-4 w-4 text-primary" />
|
|
}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<span className="font-medium">{ach?.label ?? key}</span>
|
|
{ach?.description && (
|
|
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
|
|
)}
|
|
</div>
|
|
<Badge variant="outline" className="ml-auto text-[9px] capitalize shrink-0 mt-0.5">
|
|
{ach?.type ?? "badge"}
|
|
</Badge>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
{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>{p.title ?? `Untitled (ID: ${p.ref_id})`}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</SectionCard>
|
|
)}
|
|
|
|
{course.roles?.length > 0 && (
|
|
<SectionCard icon={Users} title="Course Roles">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{course.roles.map((r, i) => (
|
|
<Badge key={r.role_id ?? i} variant="secondary">{r.text}</Badge>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
)}
|
|
|
|
{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="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>
|
|
)}
|
|
|
|
<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 ? fmtDateTime(course.createdAt) : "—"}
|
|
</InfoRow>
|
|
<InfoRow label="Updated At">
|
|
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
|
|
</InfoRow>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Tabs config ───────────────────────────────────────────────────────────────
|
|
|
|
const TABS = [
|
|
{ key: "details", label: "Course Details", icon: BookOpen },
|
|
{ key: "progress", label: "Reading Progress", icon: BarChart2 },
|
|
];
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function ViewCourse() {
|
|
const navigate = useNavigate();
|
|
const { courseId } = useParams();
|
|
const { fetchCourse, course, loading } = useCourses();
|
|
|
|
const [activeTab, setActiveTab] = useState("details");
|
|
const [instructors, setInstructors] = useState([]);
|
|
const [achievementKeys, setAchievementKeys] = useState([]);
|
|
const [achievementRegistry, setAchievementRegistry] = useState([]);
|
|
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchCourse(courseId);
|
|
|
|
api.get(`/admin/courses/${courseId}/instructors`)
|
|
.then(({ data }) => setInstructors(data.data?.data ?? data.data ?? []))
|
|
.catch(() => {});
|
|
|
|
api.get(`/admin/courses/${courseId}/achievements`)
|
|
.then(({ data }) => {
|
|
const rows = data?.data?.data ?? [];
|
|
setAchievementKeys(rows.map((r) => r.achievement_key));
|
|
})
|
|
.catch(() => {});
|
|
|
|
api.get("/admin/achievements")
|
|
.then(({ data }) => setAchievementRegistry(data.data ?? []))
|
|
.catch(() => {});
|
|
}, [courseId]);
|
|
|
|
useEffect(() => {
|
|
if (!course?.badge_asset_id) {
|
|
setBadgeImageUrl(course?.badge_image_url ?? null);
|
|
return;
|
|
}
|
|
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
|
api.post("/admin/media/token", { asset_id: course.badge_asset_id })
|
|
.then(({ data }) => {
|
|
const thumbUrl = data.data?.thumbnail_url;
|
|
const token = data.data?.token;
|
|
setBadgeImageUrl(thumbUrl ?? (token ? `${STREAM_BASE}/${token}` : null));
|
|
})
|
|
.catch(() => setBadgeImageUrl(course.badge_image_url ?? null));
|
|
}, [course?.badge_asset_id, course?.badge_image_url]);
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
|
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
|
|
|
|
{/* ── Sticky header ── */}
|
|
<div
|
|
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
|
style={{ top: "var(--navbar-h)" }}
|
|
>
|
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
|
<div className="flex items-center gap-3 py-3">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
|
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
|
View Course
|
|
</h1>
|
|
{course && (
|
|
<p className="text-sm text-muted-foreground capitalize">
|
|
{course.course_code ? `${course.course_code} — ` : ""}{course.title}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{activeTab === "details" && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
|
|
disabled={loading}
|
|
>
|
|
<Pencil className="h-4 w-4 mr-2" />
|
|
Edit Course
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Underline tabs */}
|
|
<div className="flex gap-1 pb-0 -mb-px">
|
|
{TABS.map(({ key, label, icon: Icon }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setActiveTab(key)}
|
|
className={[
|
|
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
|
activeTab === key
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
].join(" ")}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Content ── */}
|
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
|
<div className="max-w-3xl mx-auto pb-16">
|
|
{activeTab === "details" && (
|
|
<CourseDetailsTab
|
|
course={course}
|
|
loading={loading}
|
|
instructors={instructors}
|
|
achievementKeys={achievementKeys}
|
|
achievementRegistry={achievementRegistry}
|
|
badgeImageUrl={badgeImageUrl}
|
|
/>
|
|
)}
|
|
{activeTab === "progress" && (
|
|
<CourseReadingProgressList courseId={courseId} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|