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:
parent
c60384d4b1
commit
a2a2da7da0
15 changed files with 3082 additions and 6 deletions
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
86
backend/app/models/course.py
Normal file
86
backend/app/models/course.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
1089
backend/app/routers/courses.py
Normal file
1089
backend/app/routers/courses.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
135
backend/app/schemas/course.py
Normal file
135
backend/app/schemas/course.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
] : []
|
||||
|
|
|
|||
397
frontend/src/pages/CourseDetailPage.jsx
Normal file
397
frontend/src/pages/CourseDetailPage.jsx
Normal 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)' }}>
|
||||
▶
|
||||
</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>
|
||||
)
|
||||
}
|
||||
840
frontend/src/pages/CourseEditorPage.jsx
Normal file
840
frontend/src/pages/CourseEditorPage.jsx
Normal 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' }}>
|
||||
▶
|
||||
</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"
|
||||
>▲</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"
|
||||
>▼</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"
|
||||
>▲</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"
|
||||
>▼</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>
|
||||
)
|
||||
}
|
||||
286
frontend/src/pages/CoursesPage.jsx
Normal file
286
frontend/src/pages/CoursesPage.jsx
Normal 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'} · {course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} · {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' : ''} · 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' : ''} · {course.enrolled_count || 0} enrolled · {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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue