This commit is contained in:
rgrgogu
2026-08-03 14:31:51 +08:00
parent 8dfa54a731
commit 963ca25313
7 changed files with 332 additions and 234 deletions
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from 'react';
import {
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw, AlertTriangle
CheckCircle2, Circle, BookOpen, Users, Search, RefreshCcw, AlertTriangle
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
@@ -12,15 +12,10 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from '@/components/ui/dialog';
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from '@/components/ui/pagination';
import { TablePagination } from '@/components/generic/Table/TablePagination';
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
import { useDateFormat } from '@/hooks/useDateFormat';
const PAGE_SIZE = 10;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
@@ -228,63 +223,6 @@ function UserCard({ entry, onOpen }) {
);
}
// ─── Pagination controls ──────────────────────────────────────────────────────
function PaginationControls({ page, totalPages, onPage }) {
if (totalPages <= 1) return null;
const pages = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
// Show at most 5 page numbers around current
const getVisible = () => {
if (totalPages <= 5) return pages;
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, null, page - 1, page, page + 1, null, totalPages];
};
return (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
className={page === 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{getVisible().map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPage(p); }}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function CourseReadingProgressList({ courseId }) {
@@ -293,6 +231,7 @@ export default function CourseReadingProgressList({ courseId }) {
const [searchInput, setSearchInput] = useState('');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [dialogEntry, setDialogEntry] = useState(null);
useEffect(() => {
@@ -316,8 +255,19 @@ export default function CourseReadingProgressList({ courseId }) {
}, [progressList, query]);
// ── Paginate ──────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
const pagination = {
page,
limit: pageSize,
totalPages,
totalRecords: filtered.length,
hasPrevPage: page > 1,
hasNextPage: page < totalPages,
};
const handlePageChange = (p) => setPage(p);
const handlePageSizeChange = (size) => { setPageSize(size); setPage(1); };
const completedCount = progressList.filter((e) => e.course_status === 'completed').length;
const inProgressCount = progressList.length - completedCount;
@@ -407,7 +357,13 @@ export default function CourseReadingProgressList({ courseId }) {
)}
{/* ── Pagination ── */}
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
<TablePagination
pagination={pagination}
onPageChange={handlePageChange}
rowCount={paginated.length}
onPageSizeChange={handlePageSizeChange}
recordLabel="student"
/>
{/* ── Detail Dialog ── */}
<UserDetailDialog
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useCourses } from "@/contexts/AdminCoursesContext";
@@ -58,8 +58,6 @@ export default function LessonsTable({ courseId, unitId }) {
);
const rowActions = useMemo(() => buildRowActions({
onViewPage: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/page/view`),
onCreatePage: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/page`),
onView: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/view`),
onArchive: (row) => setArchiveTarget(row),
}), [courseId, unitId]);
@@ -55,9 +55,6 @@ export default function UnitsTable({ courseId, returnTo }) {
);
const rowActions = useMemo(() => buildRowActions({
onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`),
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/edit`),
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
onArchive: (row) => setArchiveTarget(row),
}), [courseId]);
@@ -1,10 +1,6 @@
import { Eye, Archive, SquarePlus, SquareChartGantt } from "lucide-react";
import { Eye, Archive } from "lucide-react";
// duration_seconds is derived from the lesson's authored content blocks
// (see duration.util.js) — zero means no page content exists yet.
const hasContent = (row) => !!row.duration_seconds;
export function buildRowActions({ onCreatePage, onViewPage, onView, onArchive }) {
export function buildRowActions({ onView, onArchive }) {
return [
{
key: "view",
@@ -12,32 +8,6 @@ export function buildRowActions({ onCreatePage, onViewPage, onView, onArchive })
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "create_content",
label: "Create Content",
icon: <SquarePlus className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onCreatePage(row),
hidden: (row) => hasContent(row),
separator: true,
},
{
key: "view_content",
label: "View Content",
icon: <SquareChartGantt className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewPage(row),
hidden: (row) => !hasContent(row),
separator: true,
},
{
key: "modify_content",
label: "Modify Content",
icon: <SquarePlus className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onCreatePage(row),
hidden: (row) => !hasContent(row),
},
{
key: "archive",
label: "Archive",
@@ -1,6 +1,6 @@
import { Eye, Archive, BookCheck, NotebookPen, ClipboardList, PlusCircle } from "lucide-react";
import { Eye, Archive } from "lucide-react";
export function buildRowActions({ onViewLessons, onView, onArchive, onQuiz, onViewQuiz }) {
export function buildRowActions({ onView, onArchive }) {
return [
{
key: "view",
@@ -8,40 +8,6 @@ export function buildRowActions({ onViewLessons, onView, onArchive, onQuiz, onVi
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "view_units",
label: "View Lessons",
icon: <BookCheck className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewLessons(row),
separator: true,
},
{
key: "create_quiz",
label: "Create Quiz",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !!(row.quiz_id || row.quiz),
separator: true,
},
{
key: "view_quiz",
label: "View Quiz",
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
separator: true,
},
{
key: "modify_quiz",
label: "Modify Quiz",
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
},
{
key: "archive",
label: "Archive",
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
import {
Pencil, ArrowLeft, Clock, ListChecks, Info, FileText, SquarePlus, SquareChartGantt,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
@@ -18,56 +20,18 @@ function InfoField({ label, value }) {
);
}
export default function ViewLesson() {
const navigate = useNavigate();
const { courseId, unitId, lessonId } = useParams();
const { fetchLesson, course, unit } = useCourses();
const { fmtDate } = useDateFormat();
const [lesson, setLesson] = useState(null);
const [initializing, setInitializing] = useState(true);
// ─── Tabs ─────────────────────────────────────────────────────────────────────
useEffect(() => {
(async () => {
const res = await fetchLesson(courseId, unitId, lessonId);
setLesson(res?.data?.data ?? null);
setInitializing(false);
})();
}, [courseId, unitId, lessonId]);
const TABS = [
{ key: "details", label: "Lesson Details", icon: Info },
{ key: "content", label: "Content", icon: FileText },
];
if (initializing) {
// ─── Details tab ──────────────────────────────────────────────────────────────
function LessonDetailsTab({ lesson, fmtDate }) {
return (
<div className="flex items-center justify-center h-64">
<Spinner className="h-6 w-6" />
</div>
);
}
return (
<section className="bg-muted h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
<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 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
<p className="text-sm text-muted-foreground">Lesson details.</p>
</div>
</div>
<Button
type="button"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
</div>
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-card rounded-lg border p-3 space-y-1">
@@ -120,6 +84,140 @@ export default function ViewLesson() {
</ul>
)}
</div>
</div>
);
}
// ─── Content tab ──────────────────────────────────────────────────────────────
function LessonContentTab({ lesson, courseId, unitId, lessonId }) {
const navigate = useNavigate();
const hasContent = !!lesson?.duration_seconds;
if (!hasContent) {
return (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<FileText className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No content has been created for this lesson yet.</p>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
>
<SquarePlus className="h-4 w-4 mr-2" />
Create Content
</Button>
</div>
);
}
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-sm font-medium">{lesson.title}</p>
<p className="text-xs text-muted-foreground">{lesson.duration_formatted ?? "0 mins"} of content</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page/view`)}
>
<SquareChartGantt className="h-4 w-4 mr-2" />
View Content
</Button>
<Button
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
>
<SquarePlus className="h-4 w-4 mr-2" />
Modify Content
</Button>
</div>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewLesson() {
const navigate = useNavigate();
const { courseId, unitId, lessonId } = useParams();
const { fetchLesson } = useCourses();
const { fmtDate } = useDateFormat();
const [lesson, setLesson] = useState(null);
const [initializing, setInitializing] = useState(true);
const [activeTab, setActiveTab] = useState("details");
useEffect(() => {
(async () => {
const res = await fetchLesson(courseId, unitId, lessonId);
setLesson(res?.data?.data ?? null);
setInitializing(false);
})();
}, [courseId, unitId, lessonId]);
if (initializing) {
return (
<div className="flex items-center justify-center h-64">
<Spinner className="h-6 w-6" />
</div>
);
}
return (
<section className="bg-muted h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
<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 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
<p className="text-sm text-muted-foreground">Lesson details.</p>
</div>
</div>
{activeTab === "details" && (
<Button
type="button"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 pb-0 -mb-px border-b">
{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>
{activeTab === "details" && <LessonDetailsTab lesson={lesson} fmtDate={fmtDate} />}
{activeTab === "content" && (
<LessonContentTab lesson={lesson} courseId={courseId} unitId={unitId} lessonId={lessonId} />
)}
</div>
</div>
@@ -1,18 +1,123 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { House, Pencil, ArrowLeft } from "lucide-react";
import {
Pencil, ArrowLeft, Info, BookCheck, ClipboardList, NotebookPen, Eye, HelpCircle,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import LessonsTable from "../../../components/courses/LessonsTable";
// ─── Tabs ─────────────────────────────────────────────────────────────────────
const TABS = [
{ key: "details", label: "Unit Details", icon: Info },
{ key: "lessons", label: "Lessons", icon: BookCheck },
{ key: "quiz", label: "Quiz", icon: ClipboardList },
];
const WIDE_TABS = new Set(["lessons"]);
// ─── Details tab ──────────────────────────────────────────────────────────────
function UnitDetailsTab({ unit }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Title</p>
<p className="text-sm font-medium">{unit?.title ?? "—"}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Description</p>
<p className="text-sm text-muted-foreground">{unit?.description || "No description provided."}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
<p className="text-sm font-medium">{unit?.order ?? 0}</p>
</div>
</div>
);
}
// ─── Quiz tab ─────────────────────────────────────────────────────────────────
function UnitQuizTab({ courseId, unitId }) {
const navigate = useNavigate();
const { fetchQuiz, quiz, loading } = useCourses();
useEffect(() => {
fetchQuiz(courseId, unitId);
}, [courseId, unitId]);
if (loading && !quiz) {
return <Skeleton className="h-32 w-full rounded-lg" />;
}
if (!quiz) {
return (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Create Quiz
</Button>
</div>
);
}
const questions = quiz.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-sm font-medium">{quiz.title || "Unit Quiz"}</p>
<p className="text-xs text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/view`)}
>
<Eye className="h-4 w-4 mr-2" />
View Quiz
</Button>
<Button
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Quiz
</Button>
</div>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewUnit() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const { fetchUnit, course } = useCourses();
const { fetchUnit } = useCourses();
const [unit, setUnit] = useState(null);
const [initializing, setInitializing] = useState(true);
const [activeTab, setActiveTab] = useState("details");
useEffect(() => {
(async () => {
@@ -33,11 +138,10 @@ export default function ViewUnit() {
return (
<section className="bg-muted h-full">
<PageMeta title={unit ? `${unit.title} - STARR` : undefined} />
<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">
<div className="flex items-center justify-between mb-6">
<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 justify-between py-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
<ArrowLeft className="h-4 w-4" />
@@ -47,6 +151,7 @@ export default function ViewUnit() {
<p className="text-sm text-muted-foreground">View unit information.</p>
</div>
</div>
{activeTab === "details" && (
<Button
type="button"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}
@@ -54,26 +159,34 @@ export default function ViewUnit() {
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
)}
</div>
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Title</p>
<p className="text-sm font-medium">{unit?.title ?? "—"}</p>
<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>
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Description</p>
<p className="text-sm text-muted-foreground">{unit?.description || "No description provided."}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
<p className="text-sm font-medium">{unit?.order ?? 0}</p>
</div>
</div>
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className={WIDE_TABS.has(activeTab) ? "pb-16" : "max-w-2xl mx-auto pb-16"}>
{activeTab === "details" && <UnitDetailsTab unit={unit} />}
{activeTab === "lessons" && <LessonsTable courseId={courseId} unitId={unitId} />}
{activeTab === "quiz" && <UnitQuizTab courseId={courseId} unitId={unitId} />}
</div>
</div>
</section>