feat: add create lesson UI

This commit is contained in:
2026-09-08 20:39:30 +07:00
parent b55e7f97c9
commit 7bc3cf4e73
14 changed files with 1534 additions and 46 deletions
@@ -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 (
<div className="group relative bg-white rounded-xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-app-border/50">
<div className="p-4 flex items-center justify-between gap-3 bg-app-surface-muted/50">
<div className="flex items-center gap-3 flex-1 min-w-0">
<HiOutlineBars3 className="w-5 h-5 text-app-text-muted cursor-grab active:cursor-grabbing hover:text-app-primary" />
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold uppercase tracking-wider flex items-center gap-1 ${typeConfig.color}`}>
<Icon className="w-3.5 h-3.5" />
{typeConfig.label}
</span>
<span className="font-medium text-sm text-app-text truncate">{section.title || `Section ${index + 1}`}</span>
{section.isFreePreview && (
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-[11px]">Free Preview</Badge>
)}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-app-text-muted font-mono mr-2">{getMetaText()}</span>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors"
>
{isExpanded ? <HiOutlineChevronUp className="w-4 h-4" /> : <HiOutlineChevronDown className="w-4 h-4" />}
</button>
<button
type="button"
onClick={onMoveUp}
disabled={index === 0}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors disabled:opacity-30"
>
<HiOutlineChevronUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors disabled:opacity-30"
>
<HiOutlineChevronDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={onDuplicate}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors"
>
<HiOutlineDocumentDuplicate className="w-4 h-4" />
</button>
<button
type="button"
onClick={onRemove}
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"
>
<HiOutlineTrash className="w-4 h-4" />
</button>
</div>
</div>
{isExpanded && Editor && (
<Editor
section={section}
onChange={(updated) => onUpdate(index, updated)}
/>
)}
</div>
);
}
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 (
<div className="flex flex-col gap-4">
<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">Sections</h3>
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted">{sections.length} items</Badge>
</div>
<p className="text-sm text-app-text-muted mt-0.5">
Drag to reorder. Each section supports different multimedia content types.
</p>
</div>
<div className="relative">
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<HiOutlinePlus className="w-4 h-4 mr-2" />
Add Section
<HiOutlineExpand className="w-4 h-4 ml-1" />
</Button>
{showAddMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-app-border z-10 py-1">
{Object.entries(SECTION_TYPES).map(([type, config]) => {
const Icon = config.icon;
return (
<button
key={type}
type="button"
onClick={() => addSection(type)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-app-text hover:bg-app-surface-muted transition-colors"
>
<Icon className={`w-4 h-4 ${config.color.split(' ')[1]}`} />
Add {config.label}
</button>
);
})}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-3">
{sections.map((section, index) => (
<SectionItem
key={section.id}
section={section}
index={index}
total={sections.length}
onUpdate={updateSection}
onRemove={() => removeSection(index)}
onDuplicate={() => duplicateSection(index)}
onMoveUp={() => moveSection(index, index - 1)}
onMoveDown={() => moveSection(index, index + 1)}
/>
))}
</div>
<div className="p-3 rounded-xl bg-white shadow-sm border border-app-border/50 flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-app-text-muted uppercase tracking-wider pl-2">Quick add:</span>
<div className="flex flex-wrap items-center gap-2">
{Object.entries(SECTION_TYPES).map(([type, config]) => {
const Icon = config.icon;
return (
<button
key={type}
type="button"
onClick={() => addSection(type)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-app-surface-muted hover:bg-app-border/40 text-app-text text-sm font-medium transition-colors"
>
<Icon className={`w-4 h-4 ${config.color.split(' ')[1]}`} />
+ {config.label}
</button>
);
})}
</div>
</div>
</div>
);
}
export default SectionManager;