-
-
-
+
+
+
+ {/* Tabs */}
+
+
+
+
+ Overview
+
+
+
+ Lessons
+
+ {lessons.length}
+
+
+
+
+ {/* Overview Tab */}
+
+
+
+
Course Details
-
- {course?.status === 'Active' ? (
-
- Active
-
+
+
+
+
+
+
+
+
+ {course.status}
+
+
+
+
+
+
+
+
+ Last updated:{' '}
+ {course.lastUpdate}
+
+
+ Total lessons:{' '}
+ {lessons.length}
+
+
+
+
+
+
+ {/* Lessons Tab */}
+
+
+ {/* Toolbar */}
+
+
+
+
Lessons
+
+ {lessons.length} items
+
+
+
+ {publishedCount} published, {draftCount} drafts
+
+
+
+
+
+
+
+
+ {/* Lessons Table */}
+ {lessons.length === 0 ? (
+
+
+
+
+ No lessons yet
+
+ Start building your course by adding the first lesson.
+
+
+
) : (
-
- Inactive
-
+
+ {/* Table Header */}
+
+
#
+
Type
+
Title
+
Duration
+
Status
+
Preview
+
Actions
+
+
+ {/* Table Rows */}
+ {lessons.map((lesson) => {
+ const tc = typeConfig[lesson.type];
+ const TypeIcon = tc.icon;
+ return (
+
+
+ {lesson.position}
+
+
+
+
+ {tc.label}
+
+
+
+
+ {lesson.title}
+
+
+
+ {lesson.duration}
+
+
+
+ {lesson.status}
+
+
+
+ {lesson.isFreePreview && (
+
+ Free
+
+ )}
+
+
+
+
+
+
+
+ );
+ })}
+
)}
-
-
-
-
-
-
+
+
>
);
}
diff --git a/src/pages/lesson/LessonFormPage.jsx b/src/pages/lesson/LessonFormPage.jsx
new file mode 100644
index 0000000..9071709
--- /dev/null
+++ b/src/pages/lesson/LessonFormPage.jsx
@@ -0,0 +1,91 @@
+import { useState } from 'react';
+import { useParams } from 'react-router-dom';
+import PageHeader from '../../components/PageHeader';
+import ActionBar from './components/ActionBar';
+import LessonMetadataForm from './components/LessonMetadataForm';
+import SectionManager from './components/SectionManager';
+import LessonSidebar from './components/LessonSidebar';
+
+const initialMetadata = {
+ title: '',
+ position: 1,
+ slug: '',
+ description: '',
+};
+
+const initialSections = [
+ {
+ id: 'section_1',
+ type: 'video',
+ title: 'Introduction to Grid 8pt System',
+ url: 'https://player.vimeo.com/video/847291039',
+ duration: '08:45',
+ thumbnail: '',
+ isFreePreview: true,
+ },
+ {
+ id: 'section_2',
+ type: 'text',
+ title: 'Breakpoint Standards & Responsive Guide',
+ content:
+ '## 1. Standard Breakpoints\n\nIn modern responsive design, the grid must follow the soft 8-pt grid structure.\n\n```\n$screens: ( sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px );\n```\n\nAll spacing (gutter, margin) must be a multiple of 8: 8px, 16px, 24px, 32px...',
+ isFreePreview: false,
+ },
+ {
+ id: 'section_3',
+ type: 'quiz',
+ title: 'Knowledge Check: Breakpoints & Fluid Grids',
+ questionCount: 5,
+ timeLimit: 10,
+ passScore: 80,
+ maxAttempts: 3,
+ isFreePreview: false,
+ },
+ {
+ id: 'section_4',
+ type: 'embed',
+ title: 'Figma Prototype Interaction',
+ embedUrl: 'https://www.figma.com/embed?embed_host=share&url=https://www.figma.com/file/rN7X3d/Responsive-Design-Library',
+ isFreePreview: false,
+ },
+];
+
+function LessonFormPage() {
+ const { courseId } = useParams();
+ const [metadata, setMetadata] = useState(initialMetadata);
+ const [sections, setSections] = useState(initialSections);
+ const [isPublished, setIsPublished] = useState(false);
+
+ return (
+ <>
+
+
+
+
setIsPublished(!isPublished)}
+ />
+
+
+
+
+
+
+
+
+ setIsPublished(!isPublished)}
+ sections={sections}
+ metadata={metadata}
+ />
+
+
+
+
+ >
+ );
+}
+
+export default LessonFormPage;
diff --git a/src/pages/lesson/components/ActionBar.jsx b/src/pages/lesson/components/ActionBar.jsx
new file mode 100644
index 0000000..51c5b40
--- /dev/null
+++ b/src/pages/lesson/components/ActionBar.jsx
@@ -0,0 +1,52 @@
+import { Button } from '../../../components/ui/button';
+import { HiOutlineArrowLeft, HiOutlineBookmark, HiOutlineRocketLaunch } from 'react-icons/hi2';
+import { Link } from 'react-router-dom';
+
+function ActionBar({ courseId, isPublished, onTogglePublish }) {
+ return (
+
+
+
+
+ Courses
+
+ /
+
+ {courseId}
+
+ /
+ New Lesson
+
+
+ Editing
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ActionBar;
diff --git a/src/pages/lesson/components/EmbedSection.jsx b/src/pages/lesson/components/EmbedSection.jsx
new file mode 100644
index 0000000..d2861dd
--- /dev/null
+++ b/src/pages/lesson/components/EmbedSection.jsx
@@ -0,0 +1,53 @@
+import { Input } from '../../../components/ui/input';
+import { Label } from '../../../components/ui/label';
+import { HiOutlineCodeBracket, HiOutlineEye, HiOutlineArrowTopRightOnSquare } from 'react-icons/hi2';
+
+function EmbedSection({ section, onChange }) {
+ const handleChange = (field, value) => {
+ onChange({ ...section, [field]: value });
+ };
+
+ return (
+
+
+
+
+
+
+
+ handleChange('embedUrl', e.target.value)}
+ placeholder="https://..."
+ className="border-0 bg-transparent font-mono text-sm focus:ring-0 focus:bg-transparent"
+ />
+
+
+
+
+
+ );
+}
+
+export default EmbedSection;
diff --git a/src/pages/lesson/components/LessonMetadataForm.jsx b/src/pages/lesson/components/LessonMetadataForm.jsx
new file mode 100644
index 0000000..cf2b241
--- /dev/null
+++ b/src/pages/lesson/components/LessonMetadataForm.jsx
@@ -0,0 +1,110 @@
+import { Card } from '../../../components/ui/card';
+import { Input } from '../../../components/ui/input';
+import { Label } from '../../../components/ui/label';
+import { Textarea } from '../../../components/ui/textarea';
+import { HiOutlineDocumentText, HiOutlineHashtag, HiOutlineArrowPath } from 'react-icons/hi2';
+
+function LessonMetadataForm({ metadata, onChange }) {
+ const handleChange = (field, value) => {
+ onChange({ ...metadata, [field]: value });
+ };
+
+ const generateSlug = () => {
+ const slug = metadata.title
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/đ/g, 'd')
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim();
+ handleChange('slug', slug);
+ };
+
+ return (
+
+
+
+
+
+
Lesson Metadata
+
+
+ schema: lessons
+
+
+
+
+
+
+ handleChange('title', e.target.value)}
+ className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
+ />
+
+
+
+
+
+
+ handleChange('position', e.target.value)}
+ className="pl-9 rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
+ />
+
+
+
+
+
+
+
+
+
+
+ /lessons/
+
+ handleChange('slug', e.target.value)}
+ className="border-0 bg-transparent font-mono focus:ring-0 focus:bg-transparent"
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default LessonMetadataForm;
diff --git a/src/pages/lesson/components/LessonSidebar.jsx b/src/pages/lesson/components/LessonSidebar.jsx
new file mode 100644
index 0000000..2c3a027
--- /dev/null
+++ b/src/pages/lesson/components/LessonSidebar.jsx
@@ -0,0 +1,225 @@
+import { Card } from '../../../components/ui/card';
+import { Switch } from '../../../components/ui/switch';
+import { Badge } from '../../../components/ui/badge';
+import { HiOutlineLockOpen } from 'react-icons/hi2';
+
+function PublishCard({ isPublished, onTogglePublish, courseTitle, moduleTitle }) {
+ return (
+
+
+
+
Publish Status
+
+
+ Draft
+
+
+
+
+
+ Publish Lesson
+ Allow students to access
+
+
+
+
+
+ Linked Course:
+ {courseTitle}
+
+
+ Module:
+ {moduleTitle}
+
+
+
+ );
+}
+
+function ContentStatsCard({ sections }) {
+ const typeCounts = sections.reduce((acc, s) => {
+ acc[s.type] = (acc[s.type] || 0) + 1;
+ return acc;
+ }, {});
+
+ const totalDuration = sections
+ .filter((s) => s.type === 'video')
+ .reduce((acc, s) => {
+ if (!s.duration) return acc;
+ const parts = s.duration.split(':').map(Number);
+ return acc + (parts[0] || 0) * 60 + (parts[1] || 0);
+ }, 0);
+
+ const formatDuration = (secs) => {
+ const m = Math.floor(secs / 60);
+ const s = secs % 60;
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ };
+
+ const totalItems = sections.length;
+ const freePreviewCount = sections.filter((s) => s.isFreePreview).length;
+
+ const typeColors = {
+ video: 'bg-app-primary',
+ text: 'bg-purple-400',
+ quiz: 'bg-orange-400',
+ embed: 'bg-gray-400',
+ };
+
+ const typeLabels = {
+ video: 'Video',
+ text: 'Text',
+ quiz: 'Quiz',
+ embed: 'Embed',
+ };
+
+ return (
+
+
+
Content Overview
+
+
+
+ Total Duration
+ {formatDuration(totalDuration)}
+ ~{Math.ceil(totalDuration / 60)} min learning
+
+
+ Sections
+ {String(totalItems).padStart(2, '0')}
+
+ {Object.entries(typeCounts)
+ .map(([t, c]) => `${c} ${typeLabels[t]}`)
+ .join(', ')}
+
+
+
+
+ {totalItems > 0 && (
+
+
+ Format distribution
+ 100%
+
+
+ {Object.entries(typeCounts).map(([type, count]) => (
+
+ ))}
+
+
+ {Object.entries(typeCounts).map(([type]) => (
+
+
+ {typeLabels[type]}
+
+ ))}
+
+
+ )}
+
+ {freePreviewCount > 0 && (
+
+
+
+
{freePreviewCount} free preview items
+
+ Non-enrolled students can view these to encourage sign-ups.
+
+
+
+ )}
+
+
+ );
+}
+
+function SeoCard({ title, slug, description }) {
+ return (
+
+
+
SEO & Search
+
+
+ academy.edu.vn > courses > {slug || 'lesson-slug'}
+
+
+ {title || 'Lesson Title'} - LMS Enterprise
+
+
+ {description || 'Lesson description will appear here...'}
+
+
+
+
+ );
+}
+
+function AuditCard({ lessonId, courseId, createdAt, updatedAt, authorId }) {
+ return (
+
+
+
System Info (Audit)
+
+
+ lesson_id:
+ {lessonId}
+
+
+ course_id:
+ {courseId}
+
+
+ created_at:
+ {createdAt}
+
+
+ updated_at:
+ {updatedAt}
+
+
+ author_id:
+ {authorId}
+
+
+
+
+ );
+}
+
+function LessonSidebar({
+ isPublished,
+ onTogglePublish,
+ sections,
+ metadata,
+}) {
+ return (
+
+ );
+}
+
+export default LessonSidebar;
diff --git a/src/pages/lesson/components/QuizSection.jsx b/src/pages/lesson/components/QuizSection.jsx
new file mode 100644
index 0000000..5ecb4e6
--- /dev/null
+++ b/src/pages/lesson/components/QuizSection.jsx
@@ -0,0 +1,50 @@
+import { HiOutlineQuestionMarkCircle, HiOutlineClock, HiOutlineAcademicCap, HiOutlinePencilSquare } from 'react-icons/hi2';
+
+function QuizSection({ section }) {
+ return (
+
+
+
+
+
+
+
+ {section.questionCount || 5} Questions
+ Multiple choice & True/False
+
+
+
+
+
+
+
+ Time: {section.timeLimit || 10} min
+ {(section.timeLimit || 10) * 60} seconds countdown
+
+
+
+
+
+
+
+ Pass: {section.passScore || 80}%
+ Max {section.maxAttempts || 3} attempts
+
+
+
+
+
+
+
+ 1. What problem does the 8-point grid system solve?
+
+
+
+
+ );
+}
+
+export default QuizSection;
diff --git a/src/pages/lesson/components/SectionManager.jsx b/src/pages/lesson/components/SectionManager.jsx
new file mode 100644
index 0000000..d081778
--- /dev/null
+++ b/src/pages/lesson/components/SectionManager.jsx
@@ -0,0 +1,266 @@
+import { useState, useRef } from 'react';
+import { Button } from '../../../components/ui/button';
+import { Badge } from '../../../components/ui/badge';
+import {
+ HiOutlineBars3,
+ HiOutlineVideoCamera,
+ HiOutlineDocumentText,
+ HiOutlineQuestionMarkCircle,
+ HiOutlineCodeBracket,
+ HiOutlineChevronUp,
+ HiOutlineChevronDown,
+ HiOutlineDocumentDuplicate,
+ HiOutlineTrash,
+ HiOutlinePlus,
+ HiOutlineChevronDown as HiOutlineExpand,
+} from 'react-icons/hi2';
+import VideoSection from './VideoSection';
+import TextSection from './TextSection';
+import QuizSection from './QuizSection';
+import EmbedSection from './EmbedSection';
+
+const SECTION_TYPES = {
+ 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 sectionEditors = {
+ video: VideoSection,
+ text: TextSection,
+ quiz: QuizSection,
+ embed: EmbedSection,
+};
+
+function SectionItem({ section, index, total, onUpdate, onRemove, onDuplicate, onMoveUp, onMoveDown }) {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const typeConfig = SECTION_TYPES[section.type];
+ const Icon = typeConfig.icon;
+ const Editor = sectionEditors[section.type];
+
+ const getMetaText = () => {
+ switch (section.type) {
+ case 'video':
+ return section.duration || '00:00';
+ case 'text':
+ return `~${Math.max(1, Math.ceil((section.content || '').split(/\s+/).length / 200))} min read`;
+ case 'quiz':
+ return `${section.timeLimit || 10}:00 \u2022 ${section.questionCount || 5} questions`;
+ case 'embed':
+ return 'iFrame';
+ default:
+ return '';
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {typeConfig.label}
+
+ {section.title || `Section ${index + 1}`}
+ {section.isFreePreview && (
+ Free Preview
+ )}
+
+
+ {getMetaText()}
+
+
+
+
+
+
+
+
+ {isExpanded && Editor && (
+
onUpdate(index, updated)}
+ />
+ )}
+
+ );
+}
+
+function SectionManager({ sections, onChange }) {
+ const [showAddMenu, setShowAddMenu] = useState(false);
+ const counterRef = useRef(0);
+
+ const addSection = (type) => {
+ counterRef.current += 1;
+ const newSection = {
+ id: `section_${counterRef.current}`,
+ type,
+ title: '',
+ isFreePreview: false,
+ ...(type === 'video' && { url: '', duration: '', thumbnail: '' }),
+ ...(type === 'text' && { content: '' }),
+ ...(type === 'quiz' && { questionCount: 5, timeLimit: 10, passScore: 80, maxAttempts: 3 }),
+ ...(type === 'embed' && { embedUrl: '' }),
+ };
+ onChange([...sections, newSection]);
+ setShowAddMenu(false);
+ };
+
+ const updateSection = (index, updated) => {
+ const newSections = [...sections];
+ newSections[index] = updated;
+ onChange(newSections);
+ };
+
+ const removeSection = (index) => {
+ onChange(sections.filter((_, i) => i !== index));
+ };
+
+ const duplicateSection = (index) => {
+ counterRef.current += 1;
+ const duplicate = { ...sections[index], id: `section_${counterRef.current}` };
+ const newSections = [...sections];
+ newSections.splice(index + 1, 0, duplicate);
+ onChange(newSections);
+ };
+
+ const moveSection = (from, to) => {
+ if (to < 0 || to >= sections.length) return;
+ const newSections = [...sections];
+ const [moved] = newSections.splice(from, 1);
+ newSections.splice(to, 0, moved);
+ onChange(newSections);
+ };
+
+ return (
+
+
+
+
+
Sections
+ {sections.length} items
+
+
+ Drag to reorder. Each section supports different multimedia content types.
+
+
+
+
+ {showAddMenu && (
+
+ {Object.entries(SECTION_TYPES).map(([type, config]) => {
+ const Icon = config.icon;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {sections.map((section, index) => (
+ removeSection(index)}
+ onDuplicate={() => duplicateSection(index)}
+ onMoveUp={() => moveSection(index, index - 1)}
+ onMoveDown={() => moveSection(index, index + 1)}
+ />
+ ))}
+
+
+
+
Quick add:
+
+ {Object.entries(SECTION_TYPES).map(([type, config]) => {
+ const Icon = config.icon;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default SectionManager;
diff --git a/src/pages/lesson/components/TextSection.jsx b/src/pages/lesson/components/TextSection.jsx
new file mode 100644
index 0000000..2b5be58
--- /dev/null
+++ b/src/pages/lesson/components/TextSection.jsx
@@ -0,0 +1,87 @@
+import { Textarea } from '../../../components/ui/textarea';
+import {
+ HiOutlineBold,
+ HiOutlineItalic,
+ HiOutlineListBullet,
+ HiOutlineListBullet as HiOutlineNumberedList,
+ HiOutlineChatBubbleLeftRight,
+ HiOutlineCodeBracket,
+ HiOutlinePhoto,
+ HiOutlineTableCells,
+ HiOutlineExclamationCircle,
+} from 'react-icons/hi2';
+
+function TextSection({ section, onChange }) {
+ const handleChange = (field, value) => {
+ onChange({ ...section, [field]: value });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Last saved at 14:32 (Auto)
+
+
+
+ );
+}
+
+export default TextSection;
diff --git a/src/pages/lesson/components/VideoSection.jsx b/src/pages/lesson/components/VideoSection.jsx
new file mode 100644
index 0000000..1e64c84
--- /dev/null
+++ b/src/pages/lesson/components/VideoSection.jsx
@@ -0,0 +1,109 @@
+import { Input } from '../../../components/ui/input';
+import { Label } from '../../../components/ui/label';
+import { HiOutlineVideoCamera, HiOutlineLink, HiOutlineClock, HiOutlineCheckBadge } from 'react-icons/hi2';
+
+function VideoSection({ section, onChange }) {
+ const handleChange = (field, value) => {
+ onChange({ ...section, [field]: value });
+ };
+
+ return (
+
+
+
+
Video Preview
+
+ {section.thumbnail ? (
+
+ ) : (
+
+ )}
+
+ {section.duration && (
+
+ {section.duration}
+
+ )}
+
+
+ H.264 • 1080p • 60fps
+
+
+
+
+
+
+
+
+ handleChange('title', e.target.value)}
+ placeholder="Enter video title..."
+ className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
+ />
+
+
+
+
+
+ handleChange('url', e.target.value)}
+ placeholder="https://..."
+ className="pl-9 rounded-xl bg-app-surface-muted border-0 font-mono text-sm focus:bg-white focus:ring-2 focus:ring-app-primary"
+ />
+
+
+
+
+
+
+
+ handleChange('duration', e.target.value)}
+ placeholder="08:45"
+ className="pl-9 rounded-xl bg-app-surface-muted border-0 font-mono text-sm focus:bg-white focus:ring-2 focus:ring-app-primary"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Video encoded and ready for streaming.
+
+
JSONB: OK
+
+
+
+
+ );
+}
+
+export default VideoSection;