From bc1a88a954e05f6b66aeb8329fc2d6cf30e63369 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 5 Apr 2026 02:49:43 +0200 Subject: [PATCH] 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) --- backend/app/routers/courses.py | 50 ++++++++++- frontend/Dockerfile | 4 +- frontend/package.json | 4 +- frontend/src/components/RichEditor.jsx | 47 +++++++++++ frontend/src/pages/CourseEditorPage.jsx | 108 ++++++++++++------------ 5 files changed, 151 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/RichEditor.jsx diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py index d38a005..79c8234 100644 --- a/backend/app/routers/courses.py +++ b/backend/app/routers/courses.py @@ -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, diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3a5439b..8ce2149 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 8e4be52..ec487f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/RichEditor.jsx b/frontend/src/components/RichEditor.jsx new file mode 100644 index 0000000..e397f56 --- /dev/null +++ b/frontend/src/components/RichEditor.jsx @@ -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 ( + { 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', + }} + /> + ) +} diff --git a/frontend/src/pages/CourseEditorPage.jsx b/frontend/src/pages/CourseEditorPage.jsx index f06778f..5560c14 100644 --- a/frontend/src/pages/CourseEditorPage.jsx +++ b/frontend/src/pages/CourseEditorPage.jsx @@ -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 }) {
- setTitle(e.target.value)} placeholder="Lesson title" /> + 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' }} />
@@ -228,35 +232,31 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
- {/* Text content */} + {/* Text content with rich editor */} {type === 'text' && (
- -