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,
"video_url": les.video_url,
"video_provider": les.video_provider,
"file_path": les.file_path,
"local_file_path": les.local_file_path,
"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,
"position": les.position,
"bbb_meeting_id": les.bbb_meeting_id,
@ -736,12 +741,12 @@ async def upload_lesson_file(
with open(file_path, "wb") as f:
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:
lesson.video_provider = "local"
db.commit()
return {"file_path": lesson.file_path}
return {"local_file_path": lesson.local_file_path}
# ── Enrollment ───────────────────────────────────────────────────────
@ -907,6 +912,45 @@ def update_progress(
# ── 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")
def ai_generate_content(
course_id: int,

View file

@ -1,8 +1,8 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build

View file

@ -15,7 +15,9 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.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": {
"@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 { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
const RichEditor = lazy(() => import('../components/RichEditor'))
const LESSON_TYPES = [
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
{ value: 'video', label: 'Video', icon: '🎬' },
@ -124,14 +126,14 @@ function QuestionBankBrowser({ onQuizCreated }) {
// --- Lesson Form ---
function LessonForm({ lesson, onSave, onCancel, courseId }) {
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 [videoUrl, setVideoUrl] = useState(lesson?.video_url || '')
const [duration, setDuration] = useState(lesson?.duration_minutes || '')
const [isRequired, setIsRequired] = useState(lesson?.is_required ?? true)
const [liveUrl, setLiveUrl] = useState(lesson?.live_url || '')
const [liveStart, setLiveStart] = useState(lesson?.live_start || '')
const [liveEnd, setLiveEnd] = useState(lesson?.live_end || '')
const [liveUrl, setLiveUrl] = useState(lesson?.live_session_url || '')
const [liveStart, setLiveStart] = useState(lesson?.live_session_start || '')
const [liveEnd, setLiveEnd] = useState(lesson?.live_session_end || '')
const [quizId, setQuizId] = useState(lesson?.quiz_id || null)
const [file, setFile] = useState(null)
const [saving, setSaving] = useState(false)
@ -146,15 +148,15 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
try {
const payload = {
title: title.trim(),
type,
lesson_type: type,
content_text: type === 'text' ? contentText : null,
video_url: type === 'video' ? videoUrl : null,
live_url: type === 'live_session' ? liveUrl : null,
live_start: type === 'live_session' && liveStart ? liveStart : null,
live_end: type === 'live_session' && liveEnd ? liveEnd : null,
live_session_url: type === 'live_session' ? liveUrl : null,
live_session_start: type === 'live_session' && liveStart ? liveStart : null,
live_session_end: type === 'live_session' && liveEnd ? liveEnd : null,
quiz_id: type === 'quiz' ? quizId : 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
@ -180,15 +182,16 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
setAiLoading(true)
setError('')
try {
const lessonId = lesson?.id
if (!lessonId) { setError('Save the lesson first to use AI generation'); setAiLoading(false); return }
const res = await api.post(`/courses/${courseId}/lessons/${lessonId}/ai-generate`, {
prompt: aiPrompt || 'Improve and expand this content.',
const endpoint = lesson?.id
? `/courses/${courseId}/lessons/${lesson.id}/ai-generate`
: `/courses/${courseId}/ai-generate`
const res = await api.post(endpoint, {
prompt: aiPrompt || `Write lesson content about: ${title}`,
action,
existing_content: action === 'refine' ? contentText : undefined,
})
if (res.data.content) {
setContentText(res.data.content)
if (res.data.generated_content) {
setContentText(res.data.generated_content)
}
} catch (err) {
setError(err.response?.data?.detail || 'AI generation failed')
@ -210,7 +213,8 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
<div className="form-group">
<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 className="grid-2">
@ -228,35 +232,31 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
</div>
</div>
{/* Text content */}
{/* Text content with rich editor */}
{type === 'text' && (
<div className="form-group">
<label>Content (Markdown)</label>
<textarea
value={contentText}
onChange={e => setContentText(e.target.value)}
rows={10}
placeholder="Write lesson content in Markdown..."
style={{ fontFamily: 'monospace', fontSize: '0.88rem' }}
/>
{lesson?.id && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<input
type="text"
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
placeholder="What should this lesson cover?"
style={{ flex: 1, minWidth: 200 }}
/>
<label>Content</label>
<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' }} />}>
<RichEditor value={contentText} onChange={setContentText} height={350} placeholder="Start writing your lesson content..." />
</Suspense>
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
placeholder="Describe what AI should write..."
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' }}
onKeyDown={e => e.key === 'Enter' && aiGenerate('generate')}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => aiGenerate('generate')}
disabled={aiLoading || !aiPrompt.trim()}
>
{aiLoading ? 'Generating...' : 'AI Generate'}
</button>
{contentText.trim() && (
<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"
onClick={() => aiGenerate('refine')}
disabled={aiLoading}
@ -514,17 +514,11 @@ export default function CourseEditorPage() {
clearMessages()
try {
if (lessonId) {
if (isFormData) {
await api.put(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`, payload)
} else {
await api.put(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`, payload)
}
// Update existing lesson
await api.put(`/courses/${courseId}/lessons/${lessonId}`, payload)
} else {
if (isFormData) {
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
} else {
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
}
// Create new lesson in module
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
}
setEditingLessonId(null)
setShowLessonForm(null)
@ -537,7 +531,7 @@ export default function CourseEditorPage() {
const deleteLesson = async (moduleId, lessonId) => {
clearMessages()
try {
await api.delete(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`)
await api.delete(`/courses/${courseId}/lessons/${lessonId}`)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete lesson')
@ -587,6 +581,7 @@ export default function CourseEditorPage() {
onChange={e => setTitle(e.target.value)}
placeholder="Course title"
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>
@ -598,6 +593,7 @@ export default function CourseEditorPage() {
rows={4}
placeholder="Describe what students will learn..."
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>
@ -644,14 +640,14 @@ export default function CourseEditorPage() {
</div>
{showModuleForm && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
type="text"
value={newModuleTitle}
onChange={e => setNewModuleTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addModule()}
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
/>
<button className="btn btn-primary btn-sm" onClick={addModule} disabled={addingModule}>