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:
Daniel 2026-04-05 02:49:43 +02:00
parent 594bbfaf26
commit bc1a88a954
5 changed files with 151 additions and 62 deletions

View file

@ -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,

View file

@ -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

View file

@ -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",

View 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',
}}
/>
)
}

View file

@ -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}>