mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
fix wip
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw, AlertTriangle
|
CheckCircle2, Circle, BookOpen, Users, Search, RefreshCcw, AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -12,15 +12,10 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import { TablePagination } from '@/components/generic/Table/TablePagination';
|
||||||
Pagination, PaginationContent, PaginationItem,
|
|
||||||
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
|
|
||||||
} from '@/components/ui/pagination';
|
|
||||||
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
||||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
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 ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CourseReadingProgressList({ courseId }) {
|
export default function CourseReadingProgressList({ courseId }) {
|
||||||
@@ -293,6 +231,7 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [dialogEntry, setDialogEntry] = useState(null);
|
const [dialogEntry, setDialogEntry] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -316,8 +255,19 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
}, [progressList, query]);
|
}, [progressList, query]);
|
||||||
|
|
||||||
// ── Paginate ──────────────────────────────────────────────────────────────
|
// ── Paginate ──────────────────────────────────────────────────────────────
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
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 completedCount = progressList.filter((e) => e.course_status === 'completed').length;
|
||||||
const inProgressCount = progressList.length - completedCount;
|
const inProgressCount = progressList.length - completedCount;
|
||||||
@@ -407,7 +357,13 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Pagination ── */}
|
{/* ── Pagination ── */}
|
||||||
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
|
<TablePagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
rowCount={paginated.length}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
recordLabel="student"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Detail Dialog ── */}
|
{/* ── Detail Dialog ── */}
|
||||||
<UserDetailDialog
|
<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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
@@ -58,8 +58,6 @@ export default function LessonsTable({ courseId, unitId }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rowActions = useMemo(() => buildRowActions({
|
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`),
|
onView: (row) => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${row.lesson_id}/view`),
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
}), [courseId, unitId]);
|
}), [courseId, unitId]);
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ export default function UnitsTable({ courseId, returnTo }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rowActions = useMemo(() => buildRowActions({
|
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`),
|
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
}), [courseId]);
|
}), [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
|
export function buildRowActions({ onView, onArchive }) {
|
||||||
// (see duration.util.js) — zero means no page content exists yet.
|
|
||||||
const hasContent = (row) => !!row.duration_seconds;
|
|
||||||
|
|
||||||
export function buildRowActions({ onCreatePage, onViewPage, onView, onArchive }) {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -12,32 +8,6 @@ export function buildRowActions({ onCreatePage, onViewPage, onView, onArchive })
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onView(row),
|
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",
|
key: "archive",
|
||||||
label: "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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -8,40 +8,6 @@ export function buildRowActions({ onViewLessons, onView, onArchive, onQuiz, onVi
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onView(row),
|
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",
|
key: "archive",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
@@ -18,13 +20,136 @@ function InfoField({ label, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "details", label: "Lesson Details", icon: Info },
|
||||||
|
{ key: "content", label: "Content", icon: FileText },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Details tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LessonDetailsTab({ lesson, fmtDate }) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
||||||
|
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card rounded-lg border p-3 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" /> Duration
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card rounded-lg border p-3 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||||
|
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card rounded-lg border p-3 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||||
|
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
<InfoField label="Title" value={lesson?.title} />
|
||||||
|
<InfoField label="Description" value={lesson?.description || "No description provided."} />
|
||||||
|
<InfoField label="Unit" value={lesson?.unit?.title} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Objectives */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium">Objectives</p>
|
||||||
|
<Badge variant="secondary" className="ml-auto">{lesson?.objectives?.length ?? 0}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lesson?.objectives?.length === 0 || !lesson?.objectives ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No objectives defined.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{lesson.objectives.map((obj, i) => (
|
||||||
|
<li key={obj.objective_id} className="flex items-start gap-3 text-sm">
|
||||||
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span>{obj.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</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() {
|
export default function ViewLesson() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId, unitId, lessonId } = useParams();
|
const { courseId, unitId, lessonId } = useParams();
|
||||||
const { fetchLesson, course, unit } = useCourses();
|
const { fetchLesson } = useCourses();
|
||||||
const { fmtDate } = useDateFormat();
|
const { fmtDate } = useDateFormat();
|
||||||
const [lesson, setLesson] = useState(null);
|
const [lesson, setLesson] = useState(null);
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -59,68 +184,41 @@ export default function ViewLesson() {
|
|||||||
<p className="text-sm text-muted-foreground">Lesson details.</p>
|
<p className="text-sm text-muted-foreground">Lesson details.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{activeTab === "details" && (
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
|
type="button"
|
||||||
>
|
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/edit`)}
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
>
|
||||||
Edit
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Edit
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
|
||||||
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-card rounded-lg border p-3 space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" /> Duration
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-card rounded-lg border p-3 space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
|
||||||
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-card rounded-lg border p-3 space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
|
||||||
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
|
||||||
<InfoField label="Title" value={lesson?.title} />
|
|
||||||
<InfoField label="Description" value={lesson?.description || "No description provided."} />
|
|
||||||
<InfoField label="Unit" value={lesson?.unit?.title} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Objectives */}
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<p className="text-sm font-medium">Objectives</p>
|
|
||||||
<Badge variant="secondary" className="ml-auto">{lesson?.objectives?.length ?? 0}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lesson?.objectives?.length === 0 || !lesson?.objectives ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">No objectives defined.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{lesson.objectives.map((obj, i) => (
|
|
||||||
<li key={obj.objective_id} className="flex items-start gap-3 text-sm">
|
|
||||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<span>{obj.text}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,18 +1,123 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
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() {
|
export default function ViewUnit() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId, unitId } = useParams();
|
const { courseId, unitId } = useParams();
|
||||||
const { fetchUnit, course } = useCourses();
|
const { fetchUnit } = useCourses();
|
||||||
const [unit, setUnit] = useState(null);
|
const [unit, setUnit] = useState(null);
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -33,11 +138,10 @@ export default function ViewUnit() {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-muted h-full">
|
<section className="bg-muted h-full">
|
||||||
<PageMeta title={unit ? `${unit.title} - STARR` : undefined} />
|
<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="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 mb-6">
|
<div className="flex items-center justify-between py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -47,35 +151,44 @@ export default function ViewUnit() {
|
|||||||
<p className="text-sm text-muted-foreground">View unit information.</p>
|
<p className="text-sm text-muted-foreground">View unit information.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{activeTab === "details" && (
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}
|
type="button"
|
||||||
>
|
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/edit`)}
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
>
|
||||||
Edit
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
<div className="flex gap-1 pb-0 -mb-px">
|
||||||
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
<div className="space-y-1">
|
<button
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Title</p>
|
key={key}
|
||||||
<p className="text-sm font-medium">{unit?.title ?? "—"}</p>
|
onClick={() => setActiveTab(key)}
|
||||||
</div>
|
className={[
|
||||||
|
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
||||||
<div className="space-y-1">
|
activeTab === key
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Description</p>
|
? "border-primary text-primary"
|
||||||
<p className="text-sm text-muted-foreground">{unit?.description || "No description provided."}</p>
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||||
</div>
|
].join(" ")}
|
||||||
|
>
|
||||||
<div className="space-y-1">
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
{label}
|
||||||
<p className="text-sm font-medium">{unit?.order ?? 0}</p>
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user