Add LMS, user quiz creation, manual questions, course system

LMS / Course System:
- Course → Module → Lesson hierarchy (any user can create)
- Lesson types: text, video (Vimeo/YouTube/local), document, quiz, live_session
- Video provider auto-detection from URL
- BBB API integration (create/join meetings) with env config
- Course enrollment with per-lesson progress tracking
- AI content generation/refinement for text lessons
- Draft/published/archived status workflow
- Subscription gate placeholder (requires_subscription flag)
- Thumbnail upload support
- Module/lesson reorder with up/down controls

User Quiz Creation:
- Any user can create quizzes from question bank (was moderator-only)
- User quizzes: is_published=0, is_shared=0 (private by default)
- Fixed section_id fallback: None instead of hardcoded 1

Manual Question Creation:
- POST /questions/create endpoint for manual MCQ entry
- CreateQuestionModal on QuestionBankPage with options, radio for correct answer
- Auto-generates embedding on creation

Frontend:
- CoursesPage: Browse/My Courses/Created tabs with search and pagination
- CourseDetailPage: Student view with module accordion, lesson viewer, progress
- CourseEditorPage: Full course builder with AI generate, question bank browser
- Courses link in Navbar
- Create Question button on Question Bank (available to all users)

Backend:
- 5 new tables: courses, course_modules, course_lessons, course_enrollments, course_lesson_progress
- Course model + schemas + router (22 endpoints)
- BBB_SERVER_URL + BBB_SECRET config
- Updated CLAUDE.md with LMS documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-05 02:07:04 +02:00
parent c60384d4b1
commit a2a2da7da0
15 changed files with 3082 additions and 6 deletions

View file

@ -1,7 +1,7 @@
# PedsHub — AI Synopsis for Codebase Work
## What this is
PedsHub is a pediatric medical learning platform. Admins upload PREP exam PDFs, AI extracts MCQ questions (or generates flashcards), and users study them with an AI tutor. Everything runs in Docker.
PedsHub is a pediatric medical learning platform with an integrated LMS. Admins upload PREP exam PDFs, AI extracts MCQ questions (or generates flashcards), and users study them with an AI tutor. Any user can create courses, quizzes, and questions. Everything runs in Docker.
## Critical rules
- **Never restart services while a Celery task is running** — check `docker compose logs celery --tail=5` first
@ -26,6 +26,18 @@ Browser → Nginx (frontend) → FastAPI (4 uvicorn workers)
├── ChromaDB (document page chunks for extraction context)
├── Redis (Celery broker, rate limits, settings, job progress, session locks)
└── Celery (2 fork workers: PDF processing, quiz extraction, flashcard generation, classification, embedding regeneration)
## Course/LMS system
Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons.
- **Lesson types**: text (markdown), video (Vimeo/YouTube/local), document, quiz (from question bank), live_session (BBB/Zoom/Meet)
- **Enrollment**: users enroll in published courses, progress tracked per-lesson
- **Video**: auto-detect provider from URL (vimeo.com → vimeo, youtube.com → youtube)
- **BBB**: full API integration (create/join/end). Config: `BBB_SERVER_URL` + `BBB_SECRET` in .env
- **AI**: generate/refine lesson text via `POST /courses/{id}/lessons/{id}/ai-generate`
- **Status**: draft (creator-only) → published (visible to all) → archived
- **Subscription gate**: `requires_subscription` flag on courses (402 on enroll if set — Stripe integration placeholder)
- **Question bank integration**: quiz lessons select questions from bank by category/tags, creates a quiz via `POST /questions/from-bank`
- **Any user can create quizzes** from the question bank (not just moderators). User quizzes are private by default (`is_published=0`, `is_shared=0`)
```
## Key directories
@ -55,6 +67,9 @@ frontend/src/
FlashcardsPage.jsx — Browse decks + card browser with search
FlashcardStudyPage.jsx — Flip cards, got-it/review, keyboard nav, progress
QuizPage.jsx — Take quiz (exam/study mode), timer, progress save to Redis
CoursesPage.jsx — Browse/enroll courses, my courses, create courses
CourseDetailPage.jsx — Student view: modules, lessons, video player, progress
CourseEditorPage.jsx — Course creator: modules, lessons, AI content, question bank browser
AdminPage.jsx — Model config, user management, settings
components/
Navbar.jsx — Auth-aware nav with jobs badge
@ -76,6 +91,11 @@ frontend/src/
| question_tag_links | Question ↔ Tag | question_id, tag_id |
| flashcard_tag_links | Flashcard ↔ Tag | flashcard_id, tag_id |
| quiz_attempts | User quiz sessions with score | quiz_id, user_id |
| courses | LMS courses (draft/published/archived) | user_id → users |
| course_modules | Sections within a course | course_id → courses |
| course_lessons | Items within a module (text/video/quiz/live) | module_id → course_modules, quiz_id → quizzes |
| course_enrollments | User enrollment + progress tracking | course_id, user_id (unique) |
| course_lesson_progress | Per-lesson completion status | enrollment_id, lesson_id (unique) |
## Common patterns
- **Tag filtering SQL**: `WHERE tag_id = ANY(:tag_ids) GROUP BY ... HAVING COUNT(DISTINCT tag_id) = :cnt` — AND logic across tags

View file

@ -41,5 +41,8 @@ class Settings(BaseSettings):
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret
settings = Settings()

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -131,7 +131,7 @@ def setup_pgvector():
from sqlalchemy import text
# Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
from app.models import flashcard # noqa
from app.models import flashcard, course # noqa
# Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
@ -319,6 +319,78 @@ def setup_pgvector():
UNIQUE (user_id, deck_id)
)
"""))
# Quiz sharing
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
# ── Course / LMS tables ──────────────────────────────────────
conn.execute(text("""
CREATE TABLE IF NOT EXISTS courses (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
description TEXT,
thumbnail_path VARCHAR,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR DEFAULT 'draft',
is_featured INTEGER DEFAULT 0,
requires_subscription INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS course_modules (
id SERIAL PRIMARY KEY,
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR NOT NULL,
description TEXT,
position INTEGER DEFAULT 0
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS course_lessons (
id SERIAL PRIMARY KEY,
module_id INTEGER NOT NULL REFERENCES course_modules(id) ON DELETE CASCADE,
title VARCHAR NOT NULL,
description TEXT,
position INTEGER DEFAULT 0,
lesson_type VARCHAR NOT NULL,
content_text TEXT,
video_url VARCHAR,
video_provider VARCHAR,
local_file_path VARCHAR,
quiz_id INTEGER REFERENCES quizzes(id) ON DELETE SET NULL,
live_session_url VARCHAR,
live_session_start TIMESTAMP,
live_session_end TIMESTAMP,
bbb_meeting_id VARCHAR,
duration_minutes INTEGER,
is_required INTEGER DEFAULT 1
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS course_enrollments (
id SERIAL PRIMARY KEY,
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
enrolled_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
progress_pct FLOAT DEFAULT 0.0,
status VARCHAR DEFAULT 'enrolled',
UNIQUE (course_id, user_id)
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS course_lesson_progress (
id SERIAL PRIMARY KEY,
enrollment_id INTEGER NOT NULL REFERENCES course_enrollments(id) ON DELETE CASCADE,
lesson_id INTEGER NOT NULL REFERENCES course_lessons(id) ON DELETE CASCADE,
status VARCHAR DEFAULT 'not_started',
started_at TIMESTAMP,
completed_at TIMESTAMP,
score FLOAT,
time_spent_seconds INTEGER DEFAULT 0,
UNIQUE (enrollment_id, lesson_id)
)
"""))
conn.commit()
@ -425,6 +497,7 @@ app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
@app.get("/api/health")

View file

@ -0,0 +1,86 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float
from sqlalchemy.orm import relationship
from app.database import Base
class Course(Base):
__tablename__ = "courses"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
thumbnail_path = Column(String, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
status = Column(String, default="draft") # draft, published, archived
is_featured = Column(Integer, default=0)
requires_subscription = Column(Integer, default=0) # 0=free, 1=paid (gate ready)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
modules = relationship("CourseModule", back_populates="course", cascade="all, delete-orphan", order_by="CourseModule.position")
enrollments = relationship("CourseEnrollment", back_populates="course", cascade="all, delete-orphan")
creator = relationship("User")
class CourseModule(Base):
__tablename__ = "course_modules"
id = Column(Integer, primary_key=True, index=True)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
position = Column(Integer, default=0)
course = relationship("Course", back_populates="modules")
lessons = relationship("CourseLesson", back_populates="module", cascade="all, delete-orphan", order_by="CourseLesson.position")
class CourseLesson(Base):
__tablename__ = "course_lessons"
id = Column(Integer, primary_key=True, index=True)
module_id = Column(Integer, ForeignKey("course_modules.id", ondelete="CASCADE"), nullable=False)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
position = Column(Integer, default=0)
lesson_type = Column(String, nullable=False) # text, video, document, quiz, live_session, scorm_package
content_text = Column(Text, nullable=True) # markdown for text lessons
video_url = Column(String, nullable=True) # Vimeo/YouTube URL
video_provider = Column(String, nullable=True) # vimeo, youtube, local
local_file_path = Column(String, nullable=True) # uploaded files
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True)
live_session_url = Column(String, nullable=True) # BBB/Zoom/Meet join link
live_session_start = Column(DateTime, nullable=True)
live_session_end = Column(DateTime, nullable=True)
bbb_meeting_id = Column(String, nullable=True) # for BBB API integration
duration_minutes = Column(Integer, nullable=True)
is_required = Column(Integer, default=1) # 1=must complete to progress
module = relationship("CourseModule", back_populates="lessons")
class CourseEnrollment(Base):
__tablename__ = "course_enrollments"
id = Column(Integer, primary_key=True, index=True)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
enrolled_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
progress_pct = Column(Float, default=0.0)
status = Column(String, default="enrolled") # enrolled, in_progress, completed
course = relationship("Course", back_populates="enrollments")
user = relationship("User")
lesson_progress = relationship("CourseLessonProgress", back_populates="enrollment", cascade="all, delete-orphan")
class CourseLessonProgress(Base):
__tablename__ = "course_lesson_progress"
id = Column(Integer, primary_key=True, index=True)
enrollment_id = Column(Integer, ForeignKey("course_enrollments.id", ondelete="CASCADE"), nullable=False)
lesson_id = Column(Integer, ForeignKey("course_lessons.id", ondelete="CASCADE"), nullable=False)
status = Column(String, default="not_started") # not_started, in_progress, completed
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
score = Column(Float, nullable=True) # for quiz lessons
time_spent_seconds = Column(Integer, default=0)
enrollment = relationship("CourseEnrollment", back_populates="lesson_progress")

View file

@ -23,6 +23,7 @@ class Quiz(Base):
created_at = Column(DateTime, default=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True) # soft delete — null = active
is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only)
is_shared = Column(Integer, default=0) # 0=private, 1=shared (visible to other users)
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")

File diff suppressed because it is too large Load diff

View file

@ -221,6 +221,60 @@ def get_question_bank(
return {"total": total, "questions": result}
class ManualQuestionCreate(BaseModel):
question_text: str
question_type: str = "mcq"
options: list[str] | None = None
correct_answer: str
explanation: str | None = None
question_category_id: int | None = None
@router.post("/create")
def create_question_manually(
data: ManualQuestionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a single question manually (not from PDF extraction)."""
if not data.question_text.strip():
raise HTTPException(status_code=400, detail="Question text is required")
if not data.correct_answer.strip():
raise HTTPException(status_code=400, detail="Correct answer is required")
if data.question_type == "mcq" and (not data.options or len(data.options) < 2):
raise HTTPException(status_code=400, detail="MCQ questions need at least 2 options")
if data.question_type == "mcq" and data.correct_answer not in data.options:
raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
question = Question(
question_text=data.question_text.strip(),
question_type=data.question_type,
options=data.options,
correct_answer=data.correct_answer.strip(),
explanation=data.explanation,
question_category_id=data.question_category_id,
)
db.add(question)
db.commit()
db.refresh(question)
# Generate embedding in background
try:
from app.services import embedding_service
embedding_service.embed_question(question)
db.commit()
except Exception:
pass
return {
"id": question.id,
"question_text": question.question_text,
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
}
class CreateFromBankRequest(BaseModel):
title: str
question_ids: list[int]
@ -232,7 +286,7 @@ class CreateFromBankRequest(BaseModel):
def create_quiz_from_bank(
data: CreateFromBankRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(get_current_user),
):
"""Create a new quiz referencing existing bank questions (no copying — edits propagate)."""
if not data.title.strip():
@ -252,7 +306,9 @@ def create_quiz_from_bank(
# Find a section_id from the source questions' origin quiz
first_src_quiz_id = source_questions[0].source_quiz_id
first_quiz = db.query(Quiz).filter(Quiz.id == first_src_quiz_id).first() if first_src_quiz_id else None
section_id = first_quiz.section_id if first_quiz else source_questions[0].source_quiz_id or 1
section_id = first_quiz.section_id if first_quiz else None
is_mod = current_user.role in ("admin", "moderator")
new_quiz = Quiz(
section_id=section_id,
@ -261,6 +317,8 @@ def create_quiz_from_bank(
questions_count=len(source_questions),
mode=data.mode,
time_limit_minutes=data.time_limit_minutes,
is_published=1 if is_mod else 0,
is_shared=1 if is_mod else 0,
)
db.add(new_quiz)
db.flush()

View file

@ -0,0 +1,135 @@
from datetime import datetime
from pydantic import BaseModel
class CourseCreate(BaseModel):
title: str
description: str | None = None
class CourseUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: str | None = None
is_featured: int | None = None
requires_subscription: int | None = None
class ModuleCreate(BaseModel):
title: str
description: str | None = None
class ModuleUpdate(BaseModel):
title: str | None = None
description: str | None = None
position: int | None = None
class LessonCreate(BaseModel):
title: str
lesson_type: str # text, video, document, quiz, live_session
description: str | None = None
content_text: str | None = None
video_url: str | None = None
local_file_path: str | None = None
quiz_id: int | None = None
live_session_url: str | None = None
live_session_start: datetime | None = None
live_session_end: datetime | None = None
duration_minutes: int | None = None
is_required: int = 1
class LessonUpdate(BaseModel):
title: str | None = None
description: str | None = None
content_text: str | None = None
video_url: str | None = None
local_file_path: str | None = None
quiz_id: int | None = None
live_session_url: str | None = None
live_session_start: datetime | None = None
live_session_end: datetime | None = None
duration_minutes: int | None = None
is_required: int | None = None
position: int | None = None
class LessonResponse(BaseModel):
id: int
module_id: int
title: str
description: str | None = None
position: int
lesson_type: str
content_text: str | None = None
video_url: str | None = None
video_provider: str | None = None
local_file_path: str | None = None
quiz_id: int | None = None
live_session_url: str | None = None
live_session_start: datetime | None = None
live_session_end: datetime | None = None
bbb_meeting_id: str | None = None
duration_minutes: int | None = None
is_required: int = 1
class Config:
from_attributes = True
class ModuleResponse(BaseModel):
id: int
course_id: int
title: str
description: str | None = None
position: int
lessons: list[LessonResponse] = []
class Config:
from_attributes = True
class CourseResponse(BaseModel):
id: int
title: str
description: str | None = None
thumbnail_path: str | None = None
user_id: int
status: str
is_featured: int = 0
requires_subscription: int = 0
created_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
class CourseDetail(CourseResponse):
modules: list[ModuleResponse] = []
creator_name: str | None = None
enrolled_count: int = 0
my_enrollment: dict | None = None
class EnrollmentResponse(BaseModel):
id: int
course_id: int
user_id: int
enrolled_at: datetime
completed_at: datetime | None = None
progress_pct: float = 0.0
status: str
class Config:
from_attributes = True
class LessonProgressUpdate(BaseModel):
status: str | None = None # in_progress, completed
time_spent_seconds: int | None = None
score: float | None = None
class AIContentRequest(BaseModel):
prompt: str
action: str = "generate" # generate, refine
existing_content: str | None = None

View file

@ -52,6 +52,7 @@ class QuizResponse(BaseModel):
created_at: datetime
deleted_at: datetime | None = None
is_published: int = 1
is_shared: int = 0
class Config:
from_attributes = True

View file

@ -24,6 +24,9 @@ import NotFoundPage from './pages/NotFoundPage'
import LandingPage from './pages/LandingPage'
import FlashcardsPage from './pages/FlashcardsPage'
import FlashcardStudyPage from './pages/FlashcardStudyPage'
import CoursesPage from './pages/CoursesPage'
import CourseDetailPage from './pages/CourseDetailPage'
import CourseEditorPage from './pages/CourseEditorPage'
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
@ -76,6 +79,9 @@ function AppRoutes() {
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>

View file

@ -93,6 +93,7 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/quizzes', label: 'Quizzes' },
{ to: '/question-bank', label: 'Question Bank' },
{ to: '/flashcards', label: 'Flashcards' },
{ to: '/courses', label: 'Courses' },
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
{ to: '/settings', label: '⚙ Settings' },
] : []

View file

@ -0,0 +1,397 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../api/client'
const LESSON_ICONS = {
text: '\u{1F4C4}',
video: '\u{1F3AC}',
document: '\u{1F4CE}',
quiz: '\u{2753}',
live_session: '\u{1F534}',
scorm_package: '\u{1F4E6}',
}
function getVideoEmbed(url, provider) {
if (provider === 'youtube') {
const m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&?#]+)/)
return m ? `https://www.youtube.com/embed/${m[1]}` : url
}
if (provider === 'vimeo') {
const m = url.match(/vimeo\.com\/(\d+)/)
return m ? `https://player.vimeo.com/video/${m[1]}` : url
}
return url
}
export default function CourseDetailPage() {
const { courseId } = useParams()
const navigate = useNavigate()
const [course, setCourse] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedModules, setExpandedModules] = useState({})
const [activeLesson, setActiveLesson] = useState(null)
const [lessonContent, setLessonContent] = useState(null)
const [loadingLesson, setLoadingLesson] = useState(false)
const [enrolling, setEnrolling] = useState(false)
const [unenrolling, setUnenrolling] = useState(false)
const [completedLessons, setCompletedLessons] = useState(new Set())
const [inProgressLessons, setInProgressLessons] = useState(new Set())
const fetchCourse = () => {
api.get(`/courses/${courseId}`).then(res => {
const data = res.data
setCourse(data)
// Build completed/in-progress sets from enrollment lesson progress if available
const completed = new Set()
const inProgress = new Set()
if (data.enrollment?.lesson_progress) {
for (const lp of data.enrollment.lesson_progress) {
if (lp.status === 'completed') completed.add(lp.lesson_id)
else if (lp.status === 'in_progress') inProgress.add(lp.lesson_id)
}
}
setCompletedLessons(completed)
setInProgressLessons(inProgress)
setLoading(false)
}).catch(() => {
setError('Failed to load course')
setLoading(false)
})
}
useEffect(() => { fetchCourse() }, [courseId])
const enroll = async () => {
setEnrolling(true)
setError('')
try {
await api.post(`/courses/${courseId}/enroll`)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to enroll')
} finally {
setEnrolling(false)
}
}
const unenroll = async () => {
setUnenrolling(true)
setError('')
try {
await api.delete(`/courses/${courseId}/enroll`)
fetchCourse()
setActiveLesson(null)
setLessonContent(null)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to unenroll')
} finally {
setUnenrolling(false)
}
}
const toggleModule = (moduleId) => {
setExpandedModules(prev => ({ ...prev, [moduleId]: !prev[moduleId] }))
}
const openLesson = async (lesson) => {
setActiveLesson(lesson)
setLessonContent(lesson)
setLoadingLesson(true)
// Mark as in_progress
if (!completedLessons.has(lesson.id)) {
try {
await api.put(`/courses/${courseId}/lessons/${lesson.id}/progress`, { status: 'in_progress' })
setInProgressLessons(prev => { const n = new Set(prev); n.add(lesson.id); return n })
} catch { /* silent */ }
}
setLoadingLesson(false)
}
const markComplete = async (lessonId) => {
try {
await api.put(`/courses/${courseId}/lessons/${lessonId}/progress`, { status: 'completed' })
setCompletedLessons(prev => { const n = new Set(prev); n.add(lessonId); return n })
setInProgressLessons(prev => { const n = new Set(prev); n.delete(lessonId); return n })
// Re-fetch to update overall progress
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to mark lesson complete')
}
}
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!course) return <div className="card"><div className="empty-state">Course not found.</div></div>
const enrolled = !!course.enrollment
const progressPct = course.enrollment?.progress_pct || 0
return (
<div>
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<h2 style={{ marginBottom: 4 }}>{course.title}</h2>
{course.description && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>{course.description}</p>
)}
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem' }}>
Created by {course.creator_name || 'Unknown'}
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{enrolled ? (
<button className="btn btn-secondary" onClick={unenroll} disabled={unenrolling}>
{unenrolling ? 'Unenrolling...' : 'Unenroll'}
</button>
) : (
<button className="btn btn-primary" onClick={enroll} disabled={enrolling}>
{enrolling ? 'Enrolling...' : 'Enroll in Course'}
</button>
)}
<button className="btn btn-secondary" onClick={() => navigate('/courses')}>Back</button>
</div>
</div>
{enrolled && (
<div style={{ marginTop: 12 }}>
<div className="progress-bar" style={{ height: 6 }}>
<div className="fill" style={{ width: `${progressPct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>{progressPct}% complete</div>
</div>
)}
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 16 }}>{error}</div>}
{!enrolled && (
<div className="card">
<div className="empty-state">
<p>Enroll in this course to access modules and lessons.</p>
</div>
</div>
)}
{/* Modules accordion */}
{enrolled && course.modules && course.modules.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: activeLesson ? 16 : 0 }}>
{course.modules.map(mod => {
const isExpanded = !!expandedModules[mod.id]
const lessons = mod.lessons || []
const completedCount = lessons.filter(l => completedLessons.has(l.id)).length
return (
<div className="card" key={mod.id} style={{ padding: 0, overflow: 'hidden' }}>
{/* Module header */}
<div
onClick={() => toggleModule(mod.id)}
style={{
padding: '14px 20px', cursor: 'pointer', display: 'flex',
justifyContent: 'space-between', alignItems: 'center',
background: isExpanded ? 'var(--bg)' : 'transparent',
transition: 'background 0.15s',
userSelect: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
&#9654;
</span>
<div>
<strong style={{ fontSize: '0.95rem' }}>{mod.title}</strong>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''}
{lessons.length > 0 && ` \u00B7 ${completedCount}/${lessons.length} completed`}
</div>
</div>
</div>
{lessons.length > 0 && completedCount === lessons.length && (
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>\u2713 Done</span>
)}
</div>
{/* Lessons list */}
{isExpanded && (
<div style={{ borderTop: '1px solid var(--border)' }}>
{lessons.length === 0 && (
<div style={{ padding: '12px 20px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>No lessons in this module.</div>
)}
{lessons.map(lesson => {
const isActive = activeLesson?.id === lesson.id
const isCompleted = completedLessons.has(lesson.id)
const icon = LESSON_ICONS[lesson.lesson_type] || '\u{1F4C4}'
return (
<div
key={lesson.id}
onClick={() => openLesson(lesson)}
style={{
padding: '10px 20px 10px 44px', cursor: 'pointer',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: isActive ? 'var(--primary-bg, rgba(59,130,246,0.08))' : 'transparent',
borderBottom: '1px solid var(--border)',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--hover-bg, rgba(0,0,0,0.03))' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '1rem' }}>{icon}</span>
<div>
<span style={{ fontSize: '0.88rem', fontWeight: isActive ? 600 : 400 }}>{lesson.title}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 1 }}>
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''}
{lesson.lesson_type === 'live_session' && lesson.scheduled_at && (
<span> \u00B7 {new Date(lesson.scheduled_at).toLocaleString()}</span>
)}
</div>
</div>
</div>
<div>
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>\u2713</span>}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
{enrolled && (!course.modules || course.modules.length === 0) && (
<div className="card"><div className="empty-state">This course has no modules yet.</div></div>
)}
{/* Lesson viewer */}
{enrolled && activeLesson && (
<div className="card" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<div>
<h3 style={{ margin: 0 }}>
{LESSON_ICONS[activeLesson.lesson_type] || '\u{1F4C4}'} {activeLesson.title}
</h3>
{activeLesson.duration_minutes && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginTop: 4 }}>{activeLesson.duration_minutes} min</p>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{!completedLessons.has(activeLesson.id) && (
<button className="btn btn-primary btn-sm" onClick={() => markComplete(activeLesson.id)}>
Mark Complete \u2713
</button>
)}
{completedLessons.has(activeLesson.id) && (
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
\u2713 Completed
</span>
)}
<button className="btn btn-secondary btn-sm" onClick={() => { setActiveLesson(null); setLessonContent(null) }}>Close</button>
</div>
</div>
{loadingLesson && <div className="loading"><div className="spinner" /></div>}
{!loadingLesson && activeLesson.lesson_type === 'text' && (
<div style={{ lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{activeLesson.content_text || 'No content available.'}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'video' && (
<div>
{activeLesson.video_provider && activeLesson.video_url ? (
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: 8 }}>
<iframe
src={getVideoEmbed(activeLesson.video_url, activeLesson.video_provider)}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : activeLesson.local_file_path ? (
<video
src={'/uploads/' + activeLesson.local_file_path}
controls
style={{ width: '100%', borderRadius: 8 }}
/>
) : (
<p style={{ color: 'var(--text-muted)' }}>No video source configured.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'quiz' && (
<div style={{ textAlign: 'center', padding: 20 }}>
<p style={{ marginBottom: 16, color: 'var(--text-muted)' }}>This lesson contains a quiz.</p>
{activeLesson.quiz_id ? (
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}`)}>
Start Quiz
</button>
) : (
<p style={{ color: 'var(--text-muted)' }}>No quiz linked to this lesson.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'live_session' && (
<div style={{ textAlign: 'center', padding: 20 }}>
{activeLesson.scheduled_at && (
<p style={{ marginBottom: 12, fontSize: '0.95rem' }}>
Scheduled: <strong>{new Date(activeLesson.scheduled_at).toLocaleString()}</strong>
</p>
)}
{activeLesson.live_session_url ? (
<a
href={activeLesson.live_session_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
Join Session
</a>
) : activeLesson.bbb_meeting_id ? (
<button
className="btn btn-primary"
onClick={() => window.open(`/api/bbb/join/${activeLesson.bbb_meeting_id}`, '_blank')}
>
Join BBB Meeting
</button>
) : (
<p style={{ color: 'var(--text-muted)' }}>No session link available yet.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'document' && (
<div style={{ textAlign: 'center', padding: 20 }}>
{activeLesson.local_file_path ? (
<a
href={'/uploads/' + activeLesson.local_file_path}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
Download Document
</a>
) : (
<p style={{ color: 'var(--text-muted)' }}>No document attached.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'scorm_package' && (
<div style={{ textAlign: 'center', padding: 20 }}>
<p style={{ color: 'var(--text-muted)' }}>SCORM package viewer is not yet available.</p>
</div>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,840 @@
import { useState, useEffect, useCallback } 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 LESSON_TYPES = [
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
{ value: 'video', label: 'Video', icon: '🎬' },
{ value: 'document', label: 'Document', icon: '📄' },
{ value: 'quiz', label: 'Quiz', icon: '✏️' },
{ value: 'live_session', label: 'Live Session', icon: '📡' },
]
const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝'
// --- Question Bank Mini-Browser ---
function QuestionBankBrowser({ onQuizCreated }) {
const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState('')
const [search, setSearch] = useState('')
const [questions, setQuestions] = useState([])
const [selected, setSelected] = useState(new Set())
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
api.get('/question-categories/').then(r => setCategories(r.data)).catch(() => {})
}, [])
const fetchQuestions = useCallback(async () => {
setLoading(true)
try {
const params = { limit: 20 }
if (categoryId) params.category_id = categoryId
if (search.trim()) params.q = search.trim()
const res = await api.get('/questions/bank', { params })
setQuestions(res.data.questions || res.data || [])
} catch {
setQuestions([])
} finally {
setLoading(false)
}
}, [categoryId, search])
useEffect(() => {
fetchQuestions()
}, [fetchQuestions])
const toggle = (qId) => {
setSelected(prev => {
const next = new Set(prev)
next.has(qId) ? next.delete(qId) : next.add(qId)
return next
})
}
const createQuiz = async () => {
if (selected.size === 0) return
setCreating(true)
setError('')
try {
const res = await api.post('/questions/from-bank', {
question_ids: [...selected],
})
onQuizCreated(res.data.quiz_id || res.data.id)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to create quiz')
} finally {
setCreating(false)
}
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 14, marginTop: 8, background: 'var(--bg)' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} style={{ flex: 1, minWidth: 140 }}>
<option value="">All categories</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search questions..."
style={{ flex: 2, minWidth: 160 }}
/>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', marginBottom: 10 }}>
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Loading...</div>}
{!loading && questions.length === 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No questions found.</div>
)}
{questions.map(q => (
<label key={q.id} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 4px', cursor: 'pointer', fontSize: '0.85rem', borderBottom: '1px solid var(--border)' }}>
<input
type="checkbox"
checked={selected.has(q.id)}
onChange={() => toggle(q.id)}
style={{ marginTop: 3 }}
/>
<span style={{ flex: 1 }}>{q.question_text?.slice(0, 120) || `Question #${q.id}`}</span>
</label>
))}
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 8, fontSize: '0.85rem' }}>{error}</div>}
<button
className="btn btn-primary btn-sm"
onClick={createQuiz}
disabled={selected.size === 0 || creating}
>
{creating ? 'Creating...' : `Create Quiz from ${selected.size} Question${selected.size !== 1 ? 's' : ''}`}
</button>
</div>
)
}
// --- Lesson Form ---
function LessonForm({ lesson, onSave, onCancel, courseId }) {
const [title, setTitle] = useState(lesson?.title || '')
const [type, setType] = useState(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 [quizId, setQuizId] = useState(lesson?.quiz_id || null)
const [file, setFile] = useState(null)
const [saving, setSaving] = useState(false)
const [aiPrompt, setAiPrompt] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [error, setError] = useState('')
const handleSave = async () => {
if (!title.trim()) { setError('Title is required'); return }
setSaving(true)
setError('')
try {
const payload = {
title: title.trim(),
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,
quiz_id: type === 'quiz' ? quizId : null,
duration_minutes: duration ? parseInt(duration) : null,
is_required: isRequired,
}
// If there's a file upload (document or video), use FormData
if (file && (type === 'document' || type === 'video')) {
const fd = new FormData()
Object.entries(payload).forEach(([k, v]) => {
if (v !== null && v !== undefined) fd.append(k, v)
})
fd.append('file', file)
onSave(fd, true)
} else {
onSave(payload, false)
}
} catch (err) {
setError(err.response?.data?.detail || 'Failed to save')
} finally {
setSaving(false)
}
}
const aiGenerate = async (action = 'generate') => {
if (!aiPrompt.trim() && action === 'generate') return
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.',
action,
existing_content: action === 'refine' ? contentText : undefined,
})
if (res.data.content) {
setContentText(res.data.content)
}
} catch (err) {
setError(err.response?.data?.detail || 'AI generation failed')
} finally {
setAiLoading(false)
}
}
const detectVideoProvider = (url) => {
if (!url) return null
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'YouTube'
if (url.includes('vimeo.com')) return 'Vimeo'
return null
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, marginTop: 8, background: 'var(--card-bg)' }}>
{error && <div className="alert alert-error" style={{ marginBottom: 10, fontSize: '0.85rem' }}>{error}</div>}
<div className="form-group">
<label>Title</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Lesson title" />
</div>
<div className="grid-2">
<div className="form-group">
<label>Type</label>
<select value={type} onChange={e => setType(e.target.value)}>
{LESSON_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.icon} {t.label}</option>
))}
</select>
</div>
<div className="form-group">
<label>Duration (minutes, optional)</label>
<input type="number" min={1} value={duration} onChange={e => setDuration(e.target.value)} placeholder="Optional" />
</div>
</div>
{/* Text content */}
{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 }}
/>
<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}
>
AI Refine
</button>
)}
</div>
)}
{!lesson?.id && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
Save the lesson first, then use AI Generate to create content.
</p>
)}
</div>
)}
{/* Video */}
{type === 'video' && (
<div className="form-group">
<label>Video URL (YouTube / Vimeo) or upload</label>
<input
type="url"
value={videoUrl}
onChange={e => setVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
/>
{detectVideoProvider(videoUrl) && (
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2, display: 'block' }}>
Detected: {detectVideoProvider(videoUrl)}
</span>
)}
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>Or upload a video file:</label>
<input type="file" accept="video/*" onChange={e => setFile(e.target.files[0])} style={{ marginTop: 4 }} />
</div>
</div>
)}
{/* Document */}
{type === 'document' && (
<div className="form-group">
<label>Upload Document</label>
<input type="file" accept=".pdf,.doc,.docx,.ppt,.pptx" onChange={e => setFile(e.target.files[0])} />
{lesson?.file_name && !file && (
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2, display: 'block' }}>
Current: {lesson.file_name}
</span>
)}
</div>
)}
{/* Quiz */}
{type === 'quiz' && (
<div className="form-group">
<label>Quiz</label>
{quizId ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '0.88rem' }}>Linked quiz #{quizId}</span>
<button className="btn btn-secondary btn-sm" onClick={() => setQuizId(null)}>Change</button>
</div>
) : (
<QuestionBankBrowser onQuizCreated={(id) => setQuizId(id)} />
)}
</div>
)}
{/* Live Session */}
{type === 'live_session' && (
<>
<div className="form-group">
<label>Meeting URL</label>
<input type="url" value={liveUrl} onChange={e => setLiveUrl(e.target.value)} placeholder="https://meet.example.com/..." />
</div>
<div className="grid-2">
<div className="form-group">
<label>Start Time</label>
<input type="datetime-local" value={liveStart} onChange={e => setLiveStart(e.target.value)} />
</div>
<div className="form-group">
<label>End Time</label>
<input type="datetime-local" value={liveEnd} onChange={e => setLiveEnd(e.target.value)} />
</div>
</div>
</>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.88rem', cursor: 'pointer' }}>
<input type="checkbox" checked={isRequired} onChange={e => setIsRequired(e.target.checked)} />
Required lesson
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : (lesson?.id ? 'Update Lesson' : 'Add Lesson')}
</button>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
</div>
</div>
)
}
// --- Main Page ---
export default function CourseEditorPage() {
const { courseId } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const [course, setCourse] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
// Course settings
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [thumbnail, setThumbnail] = useState(null)
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
// Modules
const [modules, setModules] = useState([])
const [expandedModule, setExpandedModule] = useState(null)
const [newModuleTitle, setNewModuleTitle] = useState('')
const [addingModule, setAddingModule] = useState(false)
const [showModuleForm, setShowModuleForm] = useState(false)
const [editingModuleId, setEditingModuleId] = useState(null)
const [editModuleTitle, setEditModuleTitle] = useState('')
// Lessons
const [editingLessonId, setEditingLessonId] = useState(null)
const [showLessonForm, setShowLessonForm] = useState(null) // moduleId or null
const isOwner = course && user && (course.user_id === user.id || user.role === 'admin')
const fetchCourse = useCallback(async () => {
try {
const res = await api.get(`/courses/${courseId}`)
const c = res.data
setCourse(c)
setTitle(c.title || '')
setDescription(c.description || '')
setModules(c.modules || [])
} catch (err) {
setError(err.response?.data?.detail || 'Failed to load course')
} finally {
setLoading(false)
}
}, [courseId])
useEffect(() => { fetchCourse() }, [fetchCourse])
const clearMessages = () => { setError(''); setSuccess('') }
// --- Course Settings ---
const saveCourse = async () => {
clearMessages()
setSaving(true)
try {
const payload = { title: title.trim(), description: description.trim() }
if (thumbnail) {
const fd = new FormData()
fd.append('title', payload.title)
fd.append('description', payload.description)
fd.append('thumbnail', thumbnail)
await api.put(`/courses/${courseId}`, fd)
} else {
await api.put(`/courses/${courseId}`, payload)
}
setSuccess('Course saved.')
setThumbnail(null)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to save')
} finally {
setSaving(false)
}
}
const publishCourse = async () => {
clearMessages()
setPublishing(true)
try {
await api.post(`/courses/${courseId}/publish`)
setSuccess('Course published!')
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to publish')
} finally {
setPublishing(false)
}
}
// --- Module CRUD ---
const addModule = async () => {
if (!newModuleTitle.trim()) return
setAddingModule(true)
clearMessages()
try {
await api.post(`/courses/${courseId}/modules`, { title: newModuleTitle.trim() })
setNewModuleTitle('')
setShowModuleForm(false)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to add module')
} finally {
setAddingModule(false)
}
}
const updateModule = async (moduleId) => {
if (!editModuleTitle.trim()) return
clearMessages()
try {
await api.put(`/courses/${courseId}/modules/${moduleId}`, { title: editModuleTitle.trim() })
setEditingModuleId(null)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update module')
}
}
const deleteModule = async (moduleId) => {
clearMessages()
try {
await api.delete(`/courses/${courseId}/modules/${moduleId}`)
if (expandedModule === moduleId) setExpandedModule(null)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete module')
}
}
const reorderModule = async (moduleId, direction) => {
const idx = modules.findIndex(m => m.id === moduleId)
if ((direction === 'up' && idx === 0) || (direction === 'down' && idx === modules.length - 1)) return
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
const newOrder = modules.map(m => m.id)
;[newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]]
// Optimistic update
const reordered = [...modules]
;[reordered[idx], reordered[swapIdx]] = [reordered[swapIdx], reordered[idx]]
setModules(reordered)
try {
await api.put(`/courses/${courseId}/modules/reorder`, { module_ids: newOrder })
} catch {
fetchCourse()
}
}
// --- Lesson CRUD ---
const saveLesson = async (moduleId, lessonId, payload, isFormData) => {
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)
}
} else {
if (isFormData) {
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
} else {
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
}
}
setEditingLessonId(null)
setShowLessonForm(null)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to save lesson')
}
}
const deleteLesson = async (moduleId, lessonId) => {
clearMessages()
try {
await api.delete(`/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete lesson')
}
}
const reorderLesson = async (moduleId, lessonId, direction) => {
const mod = modules.find(m => m.id === moduleId)
if (!mod) return
const lessons = mod.lessons || []
const idx = lessons.findIndex(l => l.id === lessonId)
if ((direction === 'up' && idx === 0) || (direction === 'down' && idx === lessons.length - 1)) return
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
const newOrder = lessons.map(l => l.id)
;[newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]]
// Optimistic update
const reorderedLessons = [...lessons]
;[reorderedLessons[idx], reorderedLessons[swapIdx]] = [reorderedLessons[swapIdx], reorderedLessons[idx]]
setModules(prev => prev.map(m => m.id === moduleId ? { ...m, lessons: reorderedLessons } : m))
try {
await api.put(`/courses/${courseId}/modules/${moduleId}/lessons/reorder`, { lesson_ids: newOrder })
} catch {
fetchCourse()
}
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
if (!course) return <div className="card"><p>Course not found.</p></div>
return (
<div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
{/* Course Settings */}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Course Settings</h2>
<span className={`badge badge-${course.status || 'draft'}`}>{course.status || 'draft'}</span>
</div>
<div className="form-group">
<label>Title</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Course title"
disabled={!isOwner}
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
placeholder="Describe what students will learn..."
disabled={!isOwner}
/>
</div>
<div className="form-group">
<label>Thumbnail (optional)</label>
<input
type="file"
accept="image/*"
onChange={e => setThumbnail(e.target.files[0])}
disabled={!isOwner}
/>
{course.thumbnail_url && !thumbnail && (
<img
src={course.thumbnail_url}
alt="Thumbnail"
style={{ marginTop: 8, maxHeight: 120, borderRadius: 8, objectFit: 'cover' }}
/>
)}
</div>
{isOwner && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={saveCourse} disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{course.status !== 'published' && (
<button className="btn btn-secondary" onClick={publishCourse} disabled={publishing}>
{publishing ? 'Publishing...' : 'Publish'}
</button>
)}
</div>
)}
</div>
{/* Modules */}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Modules</h2>
{isOwner && (
<button className="btn btn-primary btn-sm" onClick={() => setShowModuleForm(v => !v)}>
+ Add Module
</button>
)}
</div>
{showModuleForm && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
type="text"
value={newModuleTitle}
onChange={e => setNewModuleTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addModule()}
placeholder="Module title..."
style={{ flex: 1 }}
autoFocus
/>
<button className="btn btn-primary btn-sm" onClick={addModule} disabled={addingModule}>
{addingModule ? 'Adding...' : 'Add'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowModuleForm(false); setNewModuleTitle('') }}>
Cancel
</button>
</div>
)}
{modules.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
No modules yet. Add your first module to start building the course.
</p>
)}
{modules.map((mod, modIdx) => {
const isExpanded = expandedModule === mod.id
const lessons = mod.lessons || []
return (
<div key={mod.id} style={{ border: '1px solid var(--border)', borderRadius: 8, marginBottom: 10, overflow: 'hidden' }}>
{/* Module header */}
<div
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px',
background: isExpanded ? 'var(--bg)' : 'transparent', cursor: 'pointer',
}}
onClick={() => setExpandedModule(isExpanded ? null : mod.id)}
>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
&#9654;
</span>
{editingModuleId === mod.id ? (
<input
type="text"
value={editModuleTitle}
onChange={e => setEditModuleTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') updateModule(mod.id); if (e.key === 'Escape') setEditingModuleId(null) }}
onClick={e => e.stopPropagation()}
style={{ flex: 1 }}
autoFocus
/>
) : (
<span style={{ flex: 1, fontWeight: 600 }}>
{mod.title}
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: '0.82rem', marginLeft: 8 }}>
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''}
</span>
</span>
)}
{isOwner && (
<div style={{ display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}>
{editingModuleId === mod.id ? (
<>
<button className="btn btn-primary btn-sm" onClick={() => updateModule(mod.id)}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditingModuleId(null)}>Cancel</button>
</>
) : (
<>
<button
className="btn btn-secondary btn-sm"
style={{ padding: '2px 8px', fontSize: '0.85rem' }}
disabled={modIdx === 0}
onClick={() => reorderModule(mod.id, 'up')}
title="Move up"
>&#9650;</button>
<button
className="btn btn-secondary btn-sm"
style={{ padding: '2px 8px', fontSize: '0.85rem' }}
disabled={modIdx === modules.length - 1}
onClick={() => reorderModule(mod.id, 'down')}
title="Move down"
>&#9660;</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => { setEditingModuleId(mod.id); setEditModuleTitle(mod.title) }}
>Edit</button>
<ConfirmButton
onConfirm={() => deleteModule(mod.id)}
label="Delete"
confirmLabel="Yes, delete"
/>
</>
)}
</div>
)}
</div>
{/* Expanded: lessons */}
{isExpanded && (
<div style={{ padding: '0 14px 14px' }}>
{lessons.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', margin: '8px 0' }}>
No lessons in this module.
</p>
)}
{lessons.map((lesson, lesIdx) => (
<div key={lesson.id}>
{editingLessonId === lesson.id ? (
<LessonForm
lesson={lesson}
courseId={courseId}
onSave={(payload, isFormData) => saveLesson(mod.id, lesson.id, payload, isFormData)}
onCancel={() => setEditingLessonId(null)}
/>
) : (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderBottom: lesIdx < lessons.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<span style={{ fontSize: '0.9rem' }}>{typeIcon(lesson.type)}</span>
<span style={{ flex: 1, fontSize: '0.9rem' }}>
{lesson.title}
{lesson.is_required === false && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginLeft: 6 }}>optional</span>
)}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{lesson.type}
{lesson.duration_minutes ? ` · ${lesson.duration_minutes}m` : ''}
</span>
{isOwner && (
<div style={{ display: 'flex', gap: 4 }}>
<button
className="btn btn-secondary btn-sm"
style={{ padding: '2px 8px', fontSize: '0.85rem' }}
disabled={lesIdx === 0}
onClick={() => reorderLesson(mod.id, lesson.id, 'up')}
title="Move up"
>&#9650;</button>
<button
className="btn btn-secondary btn-sm"
style={{ padding: '2px 8px', fontSize: '0.85rem' }}
disabled={lesIdx === lessons.length - 1}
onClick={() => reorderLesson(mod.id, lesson.id, 'down')}
title="Move down"
>&#9660;</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => setEditingLessonId(lesson.id)}
>Edit</button>
<ConfirmButton
onConfirm={() => deleteLesson(mod.id, lesson.id)}
label="Delete"
confirmLabel="Yes, delete"
/>
</div>
)}
</div>
)}
</div>
))}
{/* Add lesson form */}
{showLessonForm === mod.id ? (
<LessonForm
courseId={courseId}
onSave={(payload, isFormData) => saveLesson(mod.id, null, payload, isFormData)}
onCancel={() => setShowLessonForm(null)}
/>
) : (
isOwner && (
<button
className="btn btn-secondary btn-sm"
style={{ marginTop: 10 }}
onClick={() => { setShowLessonForm(mod.id); setEditingLessonId(null) }}
>
+ Add Lesson
</button>
)
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,286 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function CoursesPage() {
const [tab, setTab] = useState('browse')
const [courses, setCourses] = useState([])
const [total, setTotal] = useState(0)
const [myCourses, setMyCourses] = useState([])
const [createdCourses, setCreatedCourses] = useState([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [deletingId, setDeletingId] = useState(null)
const [showCreateForm, setShowCreateForm] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newDescription, setNewDescription] = useState('')
const [creating, setCreating] = useState(false)
const { user } = useAuth()
const navigate = useNavigate()
const debounceRef = useRef(null)
const LIMIT = 12
const loadPublished = (off = 0, q = searchQuery) => {
setLoading(true)
const params = { limit: LIMIT, offset: off }
if (q.trim()) params.q = q.trim()
api.get('/courses/published', { params }).then(res => {
if (off === 0) setCourses(res.data.courses)
else setCourses(prev => [...prev, ...res.data.courses])
setTotal(res.data.total)
}).catch(() => {}).finally(() => setLoading(false))
}
const loadMyCourses = () => {
setLoading(true)
api.get('/courses/my-courses').then(res => {
setMyCourses(res.data)
}).catch(() => {}).finally(() => setLoading(false))
}
const loadCreated = () => {
setLoading(true)
api.get('/courses/').then(res => {
setCreatedCourses(res.data)
}).catch(() => {}).finally(() => setLoading(false))
}
useEffect(() => {
if (tab === 'browse') loadPublished(0)
if (tab === 'enrolled') loadMyCourses()
if (tab === 'created') loadCreated()
}, [tab])
useEffect(() => {
if (tab !== 'browse') return
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadPublished(0, searchQuery), 300)
return () => clearTimeout(debounceRef.current)
}, [searchQuery])
const enroll = async (courseId) => {
try {
await api.post(`/courses/${courseId}/enroll`)
setCourses(prev => prev.map(c => c.id === courseId ? { ...c, is_enrolled: true, enrolled_count: (c.enrolled_count || 0) + 1 } : c))
} catch { }
}
const unenroll = async (courseId) => {
try {
await api.delete(`/courses/${courseId}/enroll`)
setMyCourses(prev => prev.filter(c => c.id !== courseId))
} catch { }
}
const togglePublish = async (courseId, currentlyPublished) => {
try {
await api.patch(`/courses/${courseId}`, { is_published: !currentlyPublished })
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, is_published: !currentlyPublished } : c))
} catch { }
}
const deleteCourse = async (courseId) => {
try {
await api.delete(`/courses/${courseId}`)
setCreatedCourses(prev => prev.filter(c => c.id !== courseId))
setDeletingId(null)
} catch { }
}
const createCourse = async () => {
if (!newTitle.trim()) return
setCreating(true)
try {
const res = await api.post('/courses/', { title: newTitle.trim(), description: newDescription.trim() })
setCreatedCourses(prev => [res.data, ...prev])
setNewTitle('')
setNewDescription('')
setShowCreateForm(false)
} catch { }
finally { setCreating(false) }
}
if (loading && courses.length === 0 && myCourses.length === 0 && createdCourses.length === 0) {
return <div className="loading"><div className="spinner" /></div>
}
return (
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ marginBottom: 0 }}>Courses</h2>
<div style={{ display: 'flex', gap: 4 }}>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Courses</button>
<button className={`btn btn-sm ${tab === 'enrolled' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('enrolled')}>My Courses</button>
<button className={`btn btn-sm ${tab === 'created' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('created')}>My Created</button>
</div>
</div>
</div>
{/* BROWSE COURSES TAB */}
{tab === 'browse' && (
<>
<div style={{ marginBottom: 12 }}>
<input type="text" placeholder="Search courses by title..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
style={{ width: '100%', padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} course{total !== 1 ? 's' : ''}</p>
{courses.length === 0 && !loading && (
<div className="card"><div className="empty-state">No courses found.</div></div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{courses.map(course => (
<div key={course.id} className="card" style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
{course.description && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 0, lineHeight: 1.5 }}>
{course.description.length > 120 ? course.description.slice(0, 120) + '...' : course.description}
</p>
)}
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginTop: 'auto' }}>
by {course.creator_name || 'Unknown'} &middot; {course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} &middot; {course.enrolled_count || 0} enrolled
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
{course.is_enrolled ? (
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/courses/${course.id}`)}>Continue</button>
) : (
<button className="btn btn-primary btn-sm" onClick={() => enroll(course.id)}>Enroll</button>
)}
</div>
</div>
))}
</div>
{courses.length < total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button className="btn btn-secondary" onClick={() => loadPublished(courses.length, searchQuery)} disabled={loading}>
{loading ? 'Loading...' : `Load more (${total - courses.length} remaining)`}
</button>
</div>
)}
</>
)}
{/* MY COURSES (ENROLLED) TAB */}
{tab === 'enrolled' && (
<>
{myCourses.length === 0 && !loading && (
<div className="card"><div className="empty-state">You have not enrolled in any courses yet. Browse courses to get started.</div></div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{myCourses.map(course => (
<div key={course.id} className="card" style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 10 }}>
<div style={{ flex: 1, minWidth: 200, cursor: 'pointer' }} onClick={() => navigate(`/courses/${course.id}`)}>
<h3 style={{ fontSize: '1rem', marginBottom: 4 }}>{course.title}</h3>
{course.description && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 8, lineHeight: 1.5 }}>
{course.description.length > 200 ? course.description.slice(0, 200) + '...' : course.description}
</p>
)}
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginBottom: 8 }}>
{course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} &middot; by {course.creator_name || 'Unknown'}
</div>
<div className="progress-bar" style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div className="fill" style={{ width: `${course.progress || 0}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 4 }}>
{course.progress || 0}% complete
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'flex-start' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/courses/${course.id}`)}>Continue</button>
<button className="btn btn-secondary btn-sm" onClick={() => unenroll(course.id)}>Unenroll</button>
</div>
</div>
</div>
))}
</div>
</>
)}
{/* MY CREATED TAB */}
{tab === 'created' && (
<>
<div style={{ marginBottom: 12 }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowCreateForm(v => !v)}>
{showCreateForm ? 'Cancel' : '+ Create Course'}
</button>
</div>
{showCreateForm && (
<div className="card" style={{ marginBottom: 16, padding: 20 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 12 }}>New Course</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input type="text" placeholder="Course title..." value={newTitle} onChange={e => setNewTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createCourse()}
style={{ padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<textarea placeholder="Description (optional)..." value={newDescription} onChange={e => setNewDescription(e.target.value)}
rows={3}
style={{ padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)', resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={createCourse} disabled={creating || !newTitle.trim()}>
{creating ? 'Creating...' : 'Create'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowCreateForm(false); setNewTitle(''); setNewDescription('') }}>Cancel</button>
</div>
</div>
</div>
)}
{createdCourses.length === 0 && !loading && !showCreateForm && (
<div className="card"><div className="empty-state">You have not created any courses yet.</div></div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{createdCourses.map(course => (
<div key={course.id} className="card" style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 10 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
<span style={{
fontSize: '0.72rem', padding: '2px 8px', borderRadius: 10, fontWeight: 600,
background: course.is_published ? '#d1fae5' : '#fef3c7',
color: course.is_published ? '#065f46' : '#92400e',
}}>
{course.is_published ? 'Published' : 'Draft'}
</span>
</div>
{course.description && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 4, lineHeight: 1.5 }}>
{course.description.length > 200 ? course.description.slice(0, 200) + '...' : course.description}
</p>
)}
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
{course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} &middot; {course.enrolled_count || 0} enrolled &middot; {new Date(course.created_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/courses/${course.id}/edit`)}>Edit</button>
<button className="btn btn-secondary btn-sm" onClick={() => togglePublish(course.id, course.is_published)}>
{course.is_published ? 'Unpublish' : 'Publish'}
</button>
{deletingId === course.id ? (
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-danger btn-sm" onClick={() => deleteCourse(course.id)}>Yes, delete</button>
<button className="btn btn-secondary btn-sm" onClick={() => setDeletingId(null)}>Cancel</button>
</div>
) : (
<button className="btn btn-danger btn-sm" onClick={() => setDeletingId(course.id)}>Delete</button>
)}
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}

View file

@ -282,6 +282,83 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
)
}
function CreateQuestionModal({ categories, onCreated, onClose }) {
const [form, setForm] = useState({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', explanation: '', question_category_id: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const setOption = (i, val) => {
const updated = [...form.options]
const wasCorrect = form.options[i] === form.correct_answer
updated[i] = val
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
}
const save = async () => {
setSaving(true); setError('')
try {
const payload = {
question_text: form.question_text,
question_type: form.question_type,
options: form.options.filter(o => o.trim()),
correct_answer: form.correct_answer,
explanation: form.explanation || null,
question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null,
}
const res = await api.post('/questions/create', payload)
onCreated(res.data)
onClose()
} catch (err) { setError(err.response?.data?.detail || 'Failed to create question') }
finally { setSaving(false) }
}
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 560, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
<h2 style={{ marginBottom: 16 }}>Create Question</h2>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Question Text</label>
<textarea value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
rows={3} style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
</div>
<div className="form-group">
<label>Category</label>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}
style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">None</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Options</label>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem', width: 20 }}>{String.fromCharCode(65 + i)}</span>
<input value={opt} onChange={e => setOption(i, e.target.value)} placeholder={`Option ${String.fromCharCode(65 + i)}`}
style={{ flex: 1, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.85rem' }} />
<input type="radio" name="correct" checked={form.correct_answer === opt && opt !== ''} onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
title="Mark as correct" />
</div>
))}
<button className="btn btn-sm btn-secondary" onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
</div>
<div className="form-group">
<label>Explanation (optional)</label>
<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
rows={2} style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={save} disabled={saving || !form.question_text.trim() || !form.correct_answer}>
{saving ? 'Creating...' : 'Create Question'}
</button>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
)
}
export default function QuestionBankPage() {
const { dialogProps, openAlert } = useDialog()
const [questions, setQuestions] = useState([])
@ -310,6 +387,7 @@ export default function QuestionBankPage() {
const [classifyJobId, setClassifyJobId] = useState(null)
const [classifyStatus, setClassifyStatus] = useState(null)
const [classifySteps, setClassifySteps] = useState([])
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
const debounceRef = useRef(null)
const classifyPollRef = useRef(null)
const { user } = useAuth()
@ -506,6 +584,7 @@ export default function QuestionBankPage() {
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
onClose={() => setEditQuestion(null)} />}
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
{showCreateQuestion && <CreateQuestionModal categories={categories} onCreated={(q) => { setQuestions(prev => [q, ...prev]); setTotal(t => t + 1) }} onClose={() => setShowCreateQuestion(false)} />}
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
@ -515,11 +594,12 @@ export default function QuestionBankPage() {
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{total} questions total</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{isModerator && selectedIds.size > 0 && (
{selectedIds.size > 0 && (
<button className="btn btn-primary btn-sm" onClick={() => setShowCreateQuiz(true)}>
Create Quiz ({selectedIds.size} selected)
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
</div>
</div>