Fix course editor: field mismatches, TinyMCE, AI without save
Backend fixes: - Remove nonexistent fields: category, thumbnail_url, file_path, sort_order - Map to actual model fields: thumbnail_path, local_file_path, position - Add standalone AI content endpoint (no lesson save required) - Add missing lesson fields to course detail response Frontend fixes: - Fix lesson_type (was "type"), live_session_url (was "live_url") - Fix lesson save/delete URLs to match backend routes - Add TinyMCE rich text editor for text lessons (self-hosted, GPL) - AI Generate works before saving lesson (uses course-level endpoint) - Proper input styling: larger padding, border-radius, consistent sizing - Module title input properly styled Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
594bbfaf26
commit
bc1a88a954
5 changed files with 151 additions and 62 deletions
|
|
@ -336,8 +336,13 @@ def get_course(
|
||||||
"content_text": les.content_text,
|
"content_text": les.content_text,
|
||||||
"video_url": les.video_url,
|
"video_url": les.video_url,
|
||||||
"video_provider": les.video_provider,
|
"video_provider": les.video_provider,
|
||||||
"file_path": les.file_path,
|
"local_file_path": les.local_file_path,
|
||||||
"duration_minutes": les.duration_minutes,
|
"duration_minutes": les.duration_minutes,
|
||||||
|
"quiz_id": les.quiz_id,
|
||||||
|
"live_session_url": les.live_session_url,
|
||||||
|
"live_session_start": les.live_session_start,
|
||||||
|
"live_session_end": les.live_session_end,
|
||||||
|
"description": les.description,
|
||||||
"is_required": les.is_required,
|
"is_required": les.is_required,
|
||||||
"position": les.position,
|
"position": les.position,
|
||||||
"bbb_meeting_id": les.bbb_meeting_id,
|
"bbb_meeting_id": les.bbb_meeting_id,
|
||||||
|
|
@ -736,12 +741,12 @@ async def upload_lesson_file(
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
f.write(contents)
|
f.write(contents)
|
||||||
|
|
||||||
lesson.file_path = f"/uploads/course_files/{course_id}/{filename}"
|
lesson.local_file_path = f"/uploads/course_files/{course_id}/{filename}"
|
||||||
if not lesson.video_url:
|
if not lesson.video_url:
|
||||||
lesson.video_provider = "local"
|
lesson.video_provider = "local"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"file_path": lesson.file_path}
|
return {"local_file_path": lesson.local_file_path}
|
||||||
|
|
||||||
|
|
||||||
# ── Enrollment ───────────────────────────────────────────────────────
|
# ── Enrollment ───────────────────────────────────────────────────────
|
||||||
|
|
@ -907,6 +912,45 @@ def update_progress(
|
||||||
|
|
||||||
# ── AI content generation ────────────────────────────────────────────
|
# ── AI content generation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{course_id}/ai-generate")
|
||||||
|
def ai_generate_standalone(
|
||||||
|
course_id: int,
|
||||||
|
data: AIGenerateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Generate content using AI without requiring a saved lesson."""
|
||||||
|
_course_owner_or_admin(course_id, current_user, db)
|
||||||
|
|
||||||
|
from app.services.ai_service import get_model_for_task, _proxy_model
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
model_id, api_key = get_model_for_task(db, "teach")
|
||||||
|
system_msg = (
|
||||||
|
"You are a medical education content specialist for pediatrics. "
|
||||||
|
"Generate high-quality lesson content in markdown format. "
|
||||||
|
"Include relevant medical information, key concepts, and learning objectives."
|
||||||
|
)
|
||||||
|
user_msg = data.prompt
|
||||||
|
if data.existing_content:
|
||||||
|
user_msg = f"Existing content:\n{data.existing_content}\n\nInstructions: {data.prompt}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
kwargs = {"model": _proxy_model(model_id), "messages": [
|
||||||
|
{"role": "system", "content": system_msg},
|
||||||
|
{"role": "user", "content": user_msg},
|
||||||
|
], "max_tokens": 4000}
|
||||||
|
if api_key:
|
||||||
|
kwargs["api_key"] = api_key
|
||||||
|
if settings.LITELLM_API_BASE:
|
||||||
|
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||||
|
response = litellm.completion(**kwargs)
|
||||||
|
return {"generated_content": response.choices[0].message.content, "model_used": model_id}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI generation failed: {e}")
|
||||||
|
raise HTTPException(status_code=502, detail="AI generation failed")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{course_id}/lessons/{lesson_id}/ai-generate")
|
@router.post("/{course_id}/lessons/{lesson_id}/ai-generate")
|
||||||
def ai_generate_content(
|
def ai_generate_content(
|
||||||
course_id: int,
|
course_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
FROM node:18-alpine AS build
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"@tinymce/tinymce-react": "^5.1.1",
|
||||||
|
"tinymce": "^7.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
|
|
|
||||||
47
frontend/src/components/RichEditor.jsx
Normal file
47
frontend/src/components/RichEditor.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { Editor } from '@tinymce/tinymce-react'
|
||||||
|
|
||||||
|
// Self-hosted TinyMCE — no API key needed
|
||||||
|
import 'tinymce/tinymce'
|
||||||
|
import 'tinymce/models/dom'
|
||||||
|
import 'tinymce/themes/silver'
|
||||||
|
import 'tinymce/icons/default'
|
||||||
|
import 'tinymce/skins/ui/oxide/skin.min.css'
|
||||||
|
import 'tinymce/plugins/lists'
|
||||||
|
import 'tinymce/plugins/link'
|
||||||
|
import 'tinymce/plugins/image'
|
||||||
|
import 'tinymce/plugins/table'
|
||||||
|
import 'tinymce/plugins/code'
|
||||||
|
import 'tinymce/plugins/fullscreen'
|
||||||
|
import 'tinymce/plugins/autolink'
|
||||||
|
|
||||||
|
export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) {
|
||||||
|
const editorRef = useRef(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
onInit={(evt, editor) => { editorRef.current = editor }}
|
||||||
|
value={value}
|
||||||
|
onEditorChange={(content) => onChange(content)}
|
||||||
|
init={{
|
||||||
|
height,
|
||||||
|
menubar: false,
|
||||||
|
plugins: 'lists link image table code fullscreen autolink',
|
||||||
|
toolbar: 'undo redo | blocks | bold italic underline | bullist numlist | link image table | code fullscreen',
|
||||||
|
placeholder,
|
||||||
|
skin: false,
|
||||||
|
content_css: false,
|
||||||
|
content_style: `
|
||||||
|
body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #e2e8f0; background: #1e293b; padding: 12px; }
|
||||||
|
a { color: #60a5fa; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
td, th { border: 1px solid #475569; padding: 6px 10px; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
`,
|
||||||
|
branding: false,
|
||||||
|
promotion: false,
|
||||||
|
license_key: 'gpl',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, lazy, Suspense } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import ConfirmButton from '../components/ConfirmButton'
|
import ConfirmButton from '../components/ConfirmButton'
|
||||||
|
|
||||||
|
const RichEditor = lazy(() => import('../components/RichEditor'))
|
||||||
|
|
||||||
const LESSON_TYPES = [
|
const LESSON_TYPES = [
|
||||||
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
|
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
|
||||||
{ value: 'video', label: 'Video', icon: '🎬' },
|
{ value: 'video', label: 'Video', icon: '🎬' },
|
||||||
|
|
@ -124,14 +126,14 @@ function QuestionBankBrowser({ onQuizCreated }) {
|
||||||
// --- Lesson Form ---
|
// --- Lesson Form ---
|
||||||
function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
const [title, setTitle] = useState(lesson?.title || '')
|
const [title, setTitle] = useState(lesson?.title || '')
|
||||||
const [type, setType] = useState(lesson?.type || 'text')
|
const [type, setType] = useState(lesson?.lesson_type || 'text')
|
||||||
const [contentText, setContentText] = useState(lesson?.content_text || '')
|
const [contentText, setContentText] = useState(lesson?.content_text || '')
|
||||||
const [videoUrl, setVideoUrl] = useState(lesson?.video_url || '')
|
const [videoUrl, setVideoUrl] = useState(lesson?.video_url || '')
|
||||||
const [duration, setDuration] = useState(lesson?.duration_minutes || '')
|
const [duration, setDuration] = useState(lesson?.duration_minutes || '')
|
||||||
const [isRequired, setIsRequired] = useState(lesson?.is_required ?? true)
|
const [isRequired, setIsRequired] = useState(lesson?.is_required ?? true)
|
||||||
const [liveUrl, setLiveUrl] = useState(lesson?.live_url || '')
|
const [liveUrl, setLiveUrl] = useState(lesson?.live_session_url || '')
|
||||||
const [liveStart, setLiveStart] = useState(lesson?.live_start || '')
|
const [liveStart, setLiveStart] = useState(lesson?.live_session_start || '')
|
||||||
const [liveEnd, setLiveEnd] = useState(lesson?.live_end || '')
|
const [liveEnd, setLiveEnd] = useState(lesson?.live_session_end || '')
|
||||||
const [quizId, setQuizId] = useState(lesson?.quiz_id || null)
|
const [quizId, setQuizId] = useState(lesson?.quiz_id || null)
|
||||||
const [file, setFile] = useState(null)
|
const [file, setFile] = useState(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -146,15 +148,15 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
type,
|
lesson_type: type,
|
||||||
content_text: type === 'text' ? contentText : null,
|
content_text: type === 'text' ? contentText : null,
|
||||||
video_url: type === 'video' ? videoUrl : null,
|
video_url: type === 'video' ? videoUrl : null,
|
||||||
live_url: type === 'live_session' ? liveUrl : null,
|
live_session_url: type === 'live_session' ? liveUrl : null,
|
||||||
live_start: type === 'live_session' && liveStart ? liveStart : null,
|
live_session_start: type === 'live_session' && liveStart ? liveStart : null,
|
||||||
live_end: type === 'live_session' && liveEnd ? liveEnd : null,
|
live_session_end: type === 'live_session' && liveEnd ? liveEnd : null,
|
||||||
quiz_id: type === 'quiz' ? quizId : null,
|
quiz_id: type === 'quiz' ? quizId : null,
|
||||||
duration_minutes: duration ? parseInt(duration) : null,
|
duration_minutes: duration ? parseInt(duration) : null,
|
||||||
is_required: isRequired,
|
is_required: isRequired ? 1 : 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's a file upload (document or video), use FormData
|
// If there's a file upload (document or video), use FormData
|
||||||
|
|
@ -180,15 +182,16 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const lessonId = lesson?.id
|
const endpoint = lesson?.id
|
||||||
if (!lessonId) { setError('Save the lesson first to use AI generation'); setAiLoading(false); return }
|
? `/courses/${courseId}/lessons/${lesson.id}/ai-generate`
|
||||||
const res = await api.post(`/courses/${courseId}/lessons/${lessonId}/ai-generate`, {
|
: `/courses/${courseId}/ai-generate`
|
||||||
prompt: aiPrompt || 'Improve and expand this content.',
|
const res = await api.post(endpoint, {
|
||||||
|
prompt: aiPrompt || `Write lesson content about: ${title}`,
|
||||||
action,
|
action,
|
||||||
existing_content: action === 'refine' ? contentText : undefined,
|
existing_content: action === 'refine' ? contentText : undefined,
|
||||||
})
|
})
|
||||||
if (res.data.content) {
|
if (res.data.generated_content) {
|
||||||
setContentText(res.data.content)
|
setContentText(res.data.generated_content)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'AI generation failed')
|
setError(err.response?.data?.detail || 'AI generation failed')
|
||||||
|
|
@ -210,7 +213,8 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Title</label>
|
<label>Title</label>
|
||||||
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Lesson title" />
|
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Lesson title"
|
||||||
|
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.95rem', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
|
|
@ -228,35 +232,31 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text content */}
|
{/* Text content with rich editor */}
|
||||||
{type === 'text' && (
|
{type === 'text' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Content (Markdown)</label>
|
<label>Content</label>
|
||||||
<textarea
|
<Suspense fallback={<textarea value={contentText} onChange={e => setContentText(e.target.value)} rows={10} placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
|
||||||
value={contentText}
|
<RichEditor value={contentText} onChange={setContentText} height={350} placeholder="Start writing your lesson content..." />
|
||||||
onChange={e => setContentText(e.target.value)}
|
</Suspense>
|
||||||
rows={10}
|
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
placeholder="Write lesson content in Markdown..."
|
<input
|
||||||
style={{ fontFamily: 'monospace', fontSize: '0.88rem' }}
|
type="text"
|
||||||
/>
|
value={aiPrompt}
|
||||||
{lesson?.id && (
|
onChange={e => setAiPrompt(e.target.value)}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
placeholder="Describe what AI should write..."
|
||||||
<input
|
style={{ flex: 1, minWidth: 200, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}
|
||||||
type="text"
|
onKeyDown={e => e.key === 'Enter' && aiGenerate('generate')}
|
||||||
value={aiPrompt}
|
/>
|
||||||
onChange={e => setAiPrompt(e.target.value)}
|
<button
|
||||||
placeholder="What should this lesson cover?"
|
className="btn btn-primary btn-sm"
|
||||||
style={{ flex: 1, minWidth: 200 }}
|
onClick={() => aiGenerate('generate')}
|
||||||
/>
|
disabled={aiLoading || !aiPrompt.trim()}
|
||||||
|
>
|
||||||
|
{aiLoading ? 'Generating...' : 'AI Generate'}
|
||||||
|
</button>
|
||||||
|
{contentText.trim() && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => aiGenerate('generate')}
|
|
||||||
disabled={aiLoading || !aiPrompt.trim()}
|
|
||||||
>
|
|
||||||
{aiLoading ? 'Generating...' : 'AI Generate'}
|
|
||||||
</button>
|
|
||||||
{contentText.trim() && (
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => aiGenerate('refine')}
|
onClick={() => aiGenerate('refine')}
|
||||||
disabled={aiLoading}
|
disabled={aiLoading}
|
||||||
|
|
@ -514,17 +514,11 @@ export default function CourseEditorPage() {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
try {
|
try {
|
||||||
if (lessonId) {
|
if (lessonId) {
|
||||||
if (isFormData) {
|
// Update existing lesson
|
||||||
await api.put(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`, payload)
|
await api.put(`/courses/${courseId}/lessons/${lessonId}`, payload)
|
||||||
} else {
|
|
||||||
await api.put(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`, payload)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (isFormData) {
|
// Create new lesson in module
|
||||||
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
|
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
|
||||||
} else {
|
|
||||||
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setEditingLessonId(null)
|
setEditingLessonId(null)
|
||||||
setShowLessonForm(null)
|
setShowLessonForm(null)
|
||||||
|
|
@ -537,7 +531,7 @@ export default function CourseEditorPage() {
|
||||||
const deleteLesson = async (moduleId, lessonId) => {
|
const deleteLesson = async (moduleId, lessonId) => {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
try {
|
try {
|
||||||
await api.delete(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`)
|
await api.delete(`/courses/${courseId}/lessons/${lessonId}`)
|
||||||
fetchCourse()
|
fetchCourse()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Failed to delete lesson')
|
setError(err.response?.data?.detail || 'Failed to delete lesson')
|
||||||
|
|
@ -587,6 +581,7 @@ export default function CourseEditorPage() {
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
placeholder="Course title"
|
placeholder="Course title"
|
||||||
disabled={!isOwner}
|
disabled={!isOwner}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '1rem', boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -598,6 +593,7 @@ export default function CourseEditorPage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Describe what students will learn..."
|
placeholder="Describe what students will learn..."
|
||||||
disabled={!isOwner}
|
disabled={!isOwner}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.95rem', resize: 'vertical', boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -644,14 +640,14 @@ export default function CourseEditorPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModuleForm && (
|
{showModuleForm && (
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newModuleTitle}
|
value={newModuleTitle}
|
||||||
onChange={e => setNewModuleTitle(e.target.value)}
|
onChange={e => setNewModuleTitle(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && addModule()}
|
onKeyDown={e => e.key === 'Enter' && addModule()}
|
||||||
placeholder="Module title..."
|
placeholder="Module title..."
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.95rem' }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary btn-sm" onClick={addModule} disabled={addingModule}>
|
<button className="btn btn-primary btn-sm" onClick={addModule} disabled={addingModule}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue