Разработка конструктора курсов для LMS
Конструктор курсов — это интерфейс для преподавателей, где они создают структуру курса: добавляют разделы, уроки разных типов (видео, текст, тест, задание), настраивают порядок прохождения, условия разблокировки следующего урока. Ключевая задача — сделать это интуитивно без технических знаний.
Модель данных
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
description TEXT,
instructor_id UUID REFERENCES users(id),
status VARCHAR(50) DEFAULT 'draft', -- 'draft' | 'published' | 'archived'
settings JSONB DEFAULT '{}',
-- settings: { passingScore, allowSkip, sequentialMode, certificate }
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE course_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
position INTEGER NOT NULL,
is_published BOOLEAN DEFAULT false
);
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
section_id UUID REFERENCES course_sections(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
type VARCHAR(50) NOT NULL,
-- 'video' | 'text' | 'quiz' | 'assignment' | 'scorm' | 'live'
content JSONB NOT NULL DEFAULT '{}',
position INTEGER NOT NULL,
is_published BOOLEAN DEFAULT false,
is_preview BOOLEAN DEFAULT false, -- доступен без записи
duration_seconds INTEGER,
-- Условия разблокировки
unlock_condition JSONB -- { type: 'lesson_completed', lessonId: '...' }
);
API управления курсом
// Создание урока
app.post('/api/courses/:courseId/sections/:sectionId/lessons', authenticate, async (req, res) => {
const { title, type, position } = req.body;
// Дефолтный контент по типу урока
const defaultContent: Record<string, unknown> = {
video: { videoUrl: '', duration: 0, subtitles: [] },
text: { body: '' },
quiz: { questions: [], passingScore: 70, timeLimit: null },
assignment: { description: '', maxScore: 100, dueDate: null, rubric: [] },
};
const lesson = await db.lessons.create({
sectionId: req.params.sectionId,
title,
type,
content: defaultContent[type] ?? {},
position: position ?? await getNextPosition(req.params.sectionId),
});
res.json(lesson);
});
// Переупорядочивание уроков (drag-and-drop)
app.patch('/api/courses/:courseId/reorder', authenticate, async (req, res) => {
const { sections } = req.body;
// sections: [{ id, position, lessons: [{ id, position }] }]
await db.transaction(async (trx) => {
for (const section of sections) {
await trx.query(
'UPDATE course_sections SET position = $1 WHERE id = $2',
[section.position, section.id]
);
for (const lesson of section.lessons) {
await trx.query(
'UPDATE lessons SET position = $1, section_id = $2 WHERE id = $3',
[lesson.position, section.id, lesson.id] // перемещение между разделами
);
}
}
});
res.json({ ok: true });
});
React компонент конструктора
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
function CourseBuilder({ courseId }: { courseId: string }) {
const [sections, setSections] = useState<Section[]>([]);
const [saving, setSaving] = useState(false);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
// Определить, что перемещается — урок или раздел
const isLesson = sections.some(s => s.lessons.some(l => l.id === active.id));
if (!isLesson) {
// Перемещение раздела
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const reordered = arrayMove(sections, oldIndex, newIndex)
.map((s, i) => ({ ...s, position: i }));
setSections(reordered);
await saveOrder(reordered);
} else {
// Перемещение урока
const reordered = reorderLesson(sections, String(active.id), String(over.id));
setSections(reordered);
await saveOrder(reordered);
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<CourseHeader courseId={courseId} />
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sections.map(s => s.id)} strategy={verticalListSortingStrategy}>
{sections.map(section => (
<SortableSection
key={section.id}
section={section}
onAddLesson={(type) => addLesson(section.id, type)}
onUpdateLesson={updateLesson}
onDeleteLesson={deleteLesson}
/>
))}
</SortableContext>
</DndContext>
<button
onClick={addSection}
className="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl py-4 text-gray-500 hover:border-blue-400 hover:text-blue-600 transition"
>
+ Добавить раздел
</button>
</div>
);
}
function SortableSection({ section, onAddLesson, onUpdateLesson, onDeleteLesson }) {
const LESSON_TYPES = [
{ type: 'video', label: 'Видеоурок', icon: '🎬' },
{ type: 'text', label: 'Текстовый урок', icon: '📄' },
{ type: 'quiz', label: 'Тест', icon: '📝' },
{ type: 'assignment', label: 'Задание', icon: '📋' },
{ type: 'live', label: 'Живой урок', icon: '📡' },
];
return (
<div className="mb-6 bg-white border border-gray-200 rounded-xl">
<div className="p-4 flex items-center gap-3 border-b">
<span className="cursor-grab text-gray-400">⠿</span>
<input
value={section.title}
onChange={e => updateSectionTitle(section.id, e.target.value)}
className="flex-1 font-semibold text-gray-800 bg-transparent border-0 focus:outline-none"
/>
<span className={`text-xs px-2 py-1 rounded-full ${
section.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{section.is_published ? 'Опубликован' : 'Черновик'}
</span>
</div>
<SortableContext items={section.lessons.map(l => l.id)} strategy={verticalListSortingStrategy}>
{section.lessons.map(lesson => (
<SortableLesson
key={lesson.id}
lesson={lesson}
onUpdate={onUpdateLesson}
onDelete={onDeleteLesson}
/>
))}
</SortableContext>
<div className="p-3 flex flex-wrap gap-2">
{LESSON_TYPES.map(({ type, label, icon }) => (
<button
key={type}
onClick={() => onAddLesson(type)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 flex items-center gap-1"
>
{icon} {label}
</button>
))}
</div>
</div>
);
}
Редактор контента урока
Каждый тип урока имеет свой редактор:
function LessonContentEditor({ lesson, onChange }) {
switch (lesson.type) {
case 'video':
return <VideoLessonEditor content={lesson.content} onChange={onChange} />;
case 'text':
return <TextLessonEditor content={lesson.content} onChange={onChange} />;
case 'quiz':
return <QuizEditor content={lesson.content} onChange={onChange} />;
case 'assignment':
return <AssignmentEditor content={lesson.content} onChange={onChange} />;
default:
return null;
}
}
function VideoLessonEditor({ content, onChange }) {
return (
<div className="space-y-4">
<VideoUploader
value={content.videoUrl}
onChange={url => onChange({ ...content, videoUrl: url })}
/>
<input
placeholder="Продолжительность (секунды)"
type="number"
value={content.duration ?? ''}
onChange={e => onChange({ ...content, duration: Number(e.target.value) })}
className="input"
/>
<SubtitlesUploader
value={content.subtitles}
onChange={subs => onChange({ ...content, subtitles: subs })}
/>
</div>
);
}
Предварительный просмотр
Кнопка «Предпросмотр» открывает курс в режиме студента без сохранения прогресса — ?preview=true в URL.
Сроки
Конструктор курсов с drag-and-drop, типами уроков и редакторами контента — 2–3 недели.







