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,
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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 { 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}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue