Files
aplp.frontend.lms/src/pages/Course/CourseDetails.jsx
T

453 lines
18 KiB
React
Raw Normal View History

2026-09-09 22:16:47 +07:00
import { useState, useEffect } from 'react';
2026-09-08 20:39:30 +07:00
import { useParams, useNavigate } from 'react-router-dom';
2026-09-03 21:49:25 +07:00
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Label } from '../../components/ui/label';
import { Input } from '../../components/ui/input';
import { Textarea } from '../../components/ui/textarea';
2026-09-08 20:39:30 +07:00
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs';
import { Separator } from '../../components/ui/separator';
import {
HiOutlinePlus,
HiOutlinePencil,
HiOutlineTrash,
HiOutlineEye,
HiOutlineChevronRight,
HiOutlineBookOpen,
HiOutlineVideoCamera,
HiOutlineDocumentText,
HiOutlineQuestionMarkCircle,
HiOutlineCodeBracket,
HiOutlineArrowUpTray,
HiOutlineFunnel,
} from 'react-icons/hi2';
2026-09-09 22:16:47 +07:00
import courseService from '@/services/courseService';
import lessonService from '@/services/lessonService';
import sectionService from '@/services/sectionService';
2026-09-08 20:39:30 +07:00
const typeConfig = {
video: { label: 'Video', icon: HiOutlineVideoCamera, color: 'bg-blue-100 text-blue-700' },
text: { label: 'Text', icon: HiOutlineDocumentText, color: 'bg-purple-100 text-purple-700' },
quiz: { label: 'Quiz', icon: HiOutlineQuestionMarkCircle, color: 'bg-orange-100 text-orange-700' },
embed: { label: 'Embed', icon: HiOutlineCodeBracket, color: 'bg-gray-100 text-gray-700' },
};
const statusStyles = {
Published: 'bg-app-success text-app-success-text',
Draft: 'bg-app-neutral-badge text-app-neutral-badge-text',
};
2026-09-09 22:16:47 +07:00
function formatDuration(seconds) {
if (!seconds) return '00:00';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function mapCourse(c) {
return {
id: c.id,
code: c.courseCode,
title: c.title,
description: c.description,
status: c.isPublished ? 'Active' : 'Inactive',
lastUpdate: c.updatedAt,
image: c.image,
isPublished: c.isPublished,
};
}
2026-08-19 21:15:42 +07:00
function CourseDetail() {
const { courseId } = useParams();
2026-09-08 20:39:30 +07:00
const navigate = useNavigate();
2026-09-09 22:16:47 +07:00
const [course, setCourse] = useState(null);
const [lessons, setLessons] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
2026-09-08 20:39:30 +07:00
const [activeTab, setActiveTab] = useState('overview');
2026-08-19 21:15:42 +07:00
2026-09-09 22:16:47 +07:00
const handleTogglePublish = async () => {
try {
await courseService.update(courseId, {
courseCode: course.code,
title: course.title,
description: course.description,
image: course.image,
isPublished: !course.isPublished,
});
setCourse((prev) => ({ ...prev, isPublished: !prev.isPublished, status: prev.isPublished ? 'Inactive' : 'Active' }));
} catch (err) {
alert(err.message || 'Failed to update course');
}
};
const handleDeleteLesson = async (lessonId) => {
if (!window.confirm('Are you sure you want to delete this lesson?')) return;
try {
await lessonService.delete(lessonId);
setLessons((prev) => prev.filter((l) => l.id !== lessonId));
} catch (err) {
alert(err.message || 'Failed to delete lesson');
}
};
useEffect(() => {
const fetchData = async () => {
try {
const courseData = await courseService.getById(courseId);
setCourse(mapCourse(courseData));
const allLessons = await lessonService.getAll();
const courseLessons = allLessons.filter((l) => l.courseId === Number(courseId));
const lessonsWithSections = await Promise.all(
courseLessons.map(async (lesson) => {
const sections = await sectionService.getByLessonId(lesson.id);
const firstSection = sections[0];
return {
id: lesson.id,
title: lesson.title,
position: lesson.position,
type: firstSection?.type || 'text',
duration: firstSection?.type === 'video'
? formatDuration(firstSection.durationSeconds)
: firstSection?.type === 'text'
? `~${Math.max(1, Math.ceil((firstSection.content || '').split(/\s+/).length / 200))} min read`
: firstSection?.type === 'quiz'
? `${firstSection.durationSeconds ? Math.floor(firstSection.durationSeconds / 60) : 10}:00`
: firstSection?.type === 'embed'
? 'iFrame'
: '00:00',
status: lesson.isPublished ? 'Published' : 'Draft',
isFreePreview: firstSection?.isFreePreview || false,
updatedAt: lesson.updatedAt,
};
})
);
setLessons(lessonsWithSections);
} catch (err) {
setError(err.message || 'Failed to load course');
} finally {
setLoading(false);
}
};
fetchData();
}, [courseId]);
if (loading) {
return (
<>
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
</>
);
}
if (error || !course) {
2026-09-08 20:39:30 +07:00
return (
<>
<div className="flex flex-col items-center justify-center py-20 text-app-text-muted">
<HiOutlineBookOpen className="w-16 h-16 mb-4 opacity-30" />
2026-09-09 22:16:47 +07:00
<p className="text-lg font-medium">{error || 'Course not found'}</p>
2026-09-08 20:39:30 +07:00
<Button
variant="link"
className="mt-2 text-app-primary"
onClick={() => navigate('/courses')}
>
Back to Courses
</Button>
</div>
</>
);
2026-08-19 21:15:42 +07:00
}
2026-09-08 20:39:30 +07:00
const publishedCount = lessons.filter((l) => l.status === 'Published').length;
const draftCount = lessons.filter((l) => l.status === 'Draft').length;
2026-08-19 21:15:42 +07:00
return (
<>
2026-09-08 20:39:30 +07:00
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
<button
onClick={() => navigate('/courses')}
className="hover:text-app-primary transition-colors flex items-center gap-1"
>
<HiOutlineBookOpen className="w-4 h-4" />
Courses
</button>
<HiOutlineChevronRight className="w-3.5 h-3.5" />
<span className="font-medium text-app-text">{course.title}</span>
</div>
{/* Header */}
<div className="flex flex-row items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-app-text">{course.title}</h2>
<Badge
className={`rounded-[6px] text-xs font-medium ${
course.status === 'Active'
? 'bg-app-success text-app-success-text'
: 'bg-app-neutral-badge text-app-neutral-badge-text'
}`}
>
{course.status}
</Badge>
</div>
2026-09-03 21:49:25 +07:00
<div className="flex flex-row items-center gap-2">
<Button
2026-09-08 20:39:30 +07:00
variant="outline"
2026-09-03 21:49:25 +07:00
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
2026-09-09 22:16:47 +07:00
onClick={() => navigate(`/courses/${courseId}/edit`)}
>
<HiOutlinePencil className="w-4 h-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
onClick={handleTogglePublish}
2026-09-03 21:49:25 +07:00
>
2026-09-08 20:39:30 +07:00
<HiOutlineArrowUpTray className="w-4 h-4 mr-2" />
2026-09-09 22:16:47 +07:00
{course.isPublished ? 'Inactivate' : 'Activate'}
2026-08-19 21:15:42 +07:00
</Button>
2026-09-09 22:16:47 +07:00
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
onClick={handleTogglePublish}
>
{course.isPublished ? 'Unpublish' : 'Publish'}
2026-09-03 21:49:25 +07:00
</Button>
2026-08-19 21:15:42 +07:00
</div>
</div>
2026-09-08 20:39:30 +07:00
<Separator className="mb-6" />
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-6">
<TabsTrigger value="overview" className="gap-2">
<HiOutlineBookOpen className="w-4 h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="lessons" className="gap-2">
<HiOutlineDocumentText className="w-4 h-4" />
Lessons
<Badge className="ml-1 rounded-full bg-app-primary-soft text-app-primary text-[10px] px-1.5 py-0">
{lessons.length}
</Badge>
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<Card
className="w-full max-w-2xl overflow-hidden rounded-xl border border-app-border bg-white"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
<div className="p-5 border-b border-app-border">
<h4 className="font-semibold text-app-text">Course Details</h4>
2026-09-03 21:49:25 +07:00
</div>
2026-09-08 20:39:30 +07:00
<div className="px-5 pb-5 pt-4">
<div className="mb-4 w-full max-w-sm">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Course Code
</Label>
<Input value={course.code} readOnly className="bg-app-surface-muted" />
</div>
<div className="mb-4">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Status
</Label>
<Badge
className={`rounded-[6px] text-xs font-medium ${
course.status === 'Active'
? 'bg-app-success text-app-success-text'
: 'bg-app-neutral-badge text-app-neutral-badge-text'
}`}
>
{course.status}
</Badge>
</div>
<div className="mb-4 w-full max-w-sm">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Description
</Label>
<Textarea
className="max-h-40 bg-app-surface-muted"
placeholder="Course description..."
rows={4}
value={course.description}
readOnly
/>
</div>
<div className="flex items-center gap-6 text-sm text-app-text-muted">
<span>
Last updated:{' '}
2026-09-09 22:16:47 +07:00
<span className="font-medium text-app-text">
{course.lastUpdate ? new Date(course.lastUpdate).toLocaleDateString('en-GB') : '-'}
</span>
2026-09-08 20:39:30 +07:00
</span>
<span>
Total lessons:{' '}
<span className="font-medium text-app-text">{lessons.length}</span>
</span>
</div>
</div>
</Card>
</TabsContent>
{/* Lessons Tab */}
<TabsContent value="lessons">
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-app-text">Lessons</h3>
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-xs">
{lessons.length} items
</Badge>
</div>
<p className="text-sm text-app-text-muted mt-0.5">
{publishedCount} published, {draftCount} drafts
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
>
<HiOutlineFunnel className="w-4 h-4 mr-1.5" />
Filter
</Button>
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
onClick={() => navigate(`/courses/${courseId}/lessons/new`)}
>
<HiOutlinePlus className="w-4 h-4 mr-1.5" />
Add Lesson
</Button>
</div>
</div>
{/* Lessons Table */}
{lessons.length === 0 ? (
<Card
className="rounded-xl border border-app-border bg-white p-12 flex flex-col items-center justify-center text-center"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
<div className="w-16 h-16 rounded-2xl bg-app-surface-muted flex items-center justify-center mb-4">
<HiOutlineBookOpen className="w-8 h-8 text-app-text-muted/40" />
</div>
<p className="text-lg font-medium text-app-text mb-1">No lessons yet</p>
<p className="text-sm text-app-text-muted mb-4">
Start building your course by adding the first lesson.
</p>
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
onClick={() => navigate(`/courses/${courseId}/lessons/new`)}
>
<HiOutlinePlus className="w-4 h-4 mr-2" />
Add First Lesson
</Button>
</Card>
2026-08-19 21:15:42 +07:00
) : (
2026-09-08 20:39:30 +07:00
<Card
className="rounded-xl border border-app-border bg-white overflow-hidden"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-5 py-3 bg-app-surface-muted/50 border-b border-app-border text-xs font-semibold text-app-text-muted uppercase tracking-wider">
<div className="col-span-1">#</div>
<div className="col-span-1">Type</div>
<div className="col-span-4">Title</div>
<div className="col-span-2">Duration</div>
<div className="col-span-2">Status</div>
<div className="col-span-1">Preview</div>
<div className="col-span-1 text-right">Actions</div>
</div>
{/* Table Rows */}
{lessons.map((lesson) => {
2026-09-09 22:16:47 +07:00
const tc = typeConfig[lesson.type] || typeConfig.text;
2026-09-08 20:39:30 +07:00
const TypeIcon = tc.icon;
return (
<div
key={lesson.id}
className="grid grid-cols-12 gap-4 px-5 py-3.5 border-b border-app-border/50 last:border-b-0 items-center hover:bg-app-surface-muted/30 transition-colors"
>
<div className="col-span-1 text-sm text-app-text-muted font-mono">
{lesson.position}
</div>
<div className="col-span-1">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-semibold uppercase ${tc.color}`}
>
<TypeIcon className="w-3 h-3" />
{tc.label}
</span>
</div>
<div className="col-span-4">
<span className="text-sm font-medium text-app-text truncate block">
{lesson.title}
</span>
</div>
<div className="col-span-2 text-sm text-app-text-muted font-mono">
{lesson.duration}
</div>
<div className="col-span-2">
<Badge
className={`rounded-[6px] text-[11px] font-medium ${
statusStyles[lesson.status]
}`}
>
{lesson.status}
</Badge>
</div>
<div className="col-span-1">
{lesson.isFreePreview && (
<Badge className="rounded-full bg-blue-50 text-blue-600 text-[10px]">
Free
</Badge>
)}
</div>
<div className="col-span-1 flex items-center justify-end gap-1">
<button
onClick={() =>
2026-09-09 22:16:47 +07:00
navigate(`/courses/${courseId}/lessons/${lesson.id}/edit`)
2026-09-08 20:39:30 +07:00
}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
title="Edit"
>
<HiOutlinePencil className="w-4 h-4" />
</button>
<button
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
title="Preview"
>
<HiOutlineEye className="w-4 h-4" />
</button>
<button
2026-09-09 22:16:47 +07:00
onClick={() => handleDeleteLesson(lesson.id)}
2026-09-08 20:39:30 +07:00
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-red-500 hover:bg-red-50 transition-colors"
title="Delete"
>
<HiOutlineTrash className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</Card>
2026-08-19 21:15:42 +07:00
)}
2026-09-03 21:49:25 +07:00
</div>
2026-09-08 20:39:30 +07:00
</TabsContent>
</Tabs>
2026-08-19 21:15:42 +07:00
</>
);
}
export default CourseDetail;