diff --git a/backend/alembic/versions/e8d4f1a27c93_articles_cards.py b/backend/alembic/versions/e8d4f1a27c93_articles_cards.py new file mode 100644 index 0000000..117ddcf --- /dev/null +++ b/backend/alembic/versions/e8d4f1a27c93_articles_cards.py @@ -0,0 +1,67 @@ +"""Topic articles and card/question associations. + +Revision ID: e8d4f1a27c93 +Revises: d94a26b8f302 +""" +from alembic import op +import sqlalchemy as sa + +revision = "e8d4f1a27c93" +down_revision = "d94a26b8f302" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS articles ( + id SERIAL PRIMARY KEY, + slug VARCHAR(120) NOT NULL UNIQUE, + title VARCHAR(300) NOT NULL, + summary TEXT, + content TEXT, + sections JSON NOT NULL DEFAULT '[]', + category_id INTEGER REFERENCES question_categories(id) ON DELETE SET NULL, + section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )""") + op.execute("CREATE INDEX IF NOT EXISTS ix_articles_id ON articles (id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_articles_slug ON articles (slug)") + op.execute(""" + CREATE TABLE IF NOT EXISTS question_article_links ( + id SERIAL PRIMARY KEY, + question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + section_id VARCHAR(64), + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_question_article_section UNIQUE (question_id, article_id, section_id) + )""") + op.execute("CREATE INDEX IF NOT EXISTS ix_question_article_links_id ON question_article_links (id)") + op.execute(""" + CREATE TABLE IF NOT EXISTS flashcard_question_links ( + id SERIAL PRIMARY KEY, + flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + CONSTRAINT uq_card_question UNIQUE (flashcard_id, question_id) + )""") + op.execute("CREATE INDEX IF NOT EXISTS ix_flashcard_question_links_id ON flashcard_question_links (id)") + op.execute(""" + CREATE TABLE IF NOT EXISTS flashcard_article_links ( + id SERIAL PRIMARY KEY, + flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + article_section_id VARCHAR(64), + CONSTRAINT uq_card_article_section UNIQUE (flashcard_id, article_id, article_section_id) + )""") + op.execute("CREATE INDEX IF NOT EXISTS ix_flashcard_article_links_id ON flashcard_article_links (id)") + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS flashcard_article_links") + op.execute("DROP TABLE IF EXISTS flashcard_question_links") + op.execute("DROP TABLE IF EXISTS question_article_links") + op.execute("DROP TABLE IF EXISTS articles") diff --git a/backend/app/main.py b/backend/app/main.py index 2572c02..98b521a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from app.logging_config import setup_logging setup_logging(settings.LOG_LEVEL) 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, courses, mobile, mynote -from app.routers import study_tools, uploads +from app.routers import study_tools, uploads, articles from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -616,6 +616,7 @@ app.add_middleware(RequestLoggingMiddleware) app.include_router(uploads.router) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3ac7fd4..d318ef3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,8 @@ from app.models.ai_model_config import AIModelConfig from app.models.favorite import Favorite from app.models.user_note import UserNote from app.models.lab_reference import LabReference +from app.models.article import Article, QuestionArticleLink +from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink __all__ = [ "User", @@ -23,4 +25,11 @@ __all__ = [ "Favorite", "UserNote", "LabReference", + "Article", + "QuestionArticleLink", + "FlashcardDeck", + "Flashcard", + "FlashcardDeckRating", + "FlashcardQuestionLink", + "FlashcardArticleLink", ] diff --git a/backend/app/models/article.py b/backend/app/models/article.py new file mode 100644 index 0000000..9cd7bef --- /dev/null +++ b/backend/app/models/article.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from sqlalchemy import Column, Integer, String, Text, JSON, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship + +from app.database import Base + + +class Article(Base): + """Educator-authored topic reading with stable section IDs for linking.""" + + __tablename__ = "articles" + + id = Column(Integer, primary_key=True, index=True) + slug = Column(String(120), unique=True, nullable=False, index=True) + title = Column(String(300), nullable=False) + summary = Column(Text, nullable=True) + content = Column(Text, nullable=True) # Whole-article introduction (markdown). + # Stable subsections: [{"id": uuid-hex, "slug": "...", "title": "...", "content": "markdown"}]. + sections = Column(JSON, nullable=False, default=list) + category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True) + section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True) # Optional PDF source range. + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + status = Column(String, default="draft") # draft | published + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + category = relationship("QuestionCategory") + links = relationship("QuestionArticleLink", back_populates="article", cascade="all, delete-orphan") + + +class QuestionArticleLink(Base): + """Question -> whole article or one stable section.""" + + __tablename__ = "question_article_links" + __table_args__ = (UniqueConstraint("question_id", "article_id", "section_id", name="uq_question_article_section"),) + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) + article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False) + section_id = Column(String(64), nullable=True) # Article.sections[].id; None = whole article. + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + article = relationship("Article", back_populates="links") diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py index 51c327a..2ea23fc 100644 --- a/backend/app/models/flashcard.py +++ b/backend/app/models/flashcard.py @@ -38,3 +38,22 @@ class Flashcard(Base): image_path = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) deck = relationship("FlashcardDeck", back_populates="cards") + + +class FlashcardQuestionLink(Base): + __tablename__ = "flashcard_question_links" + __table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),) + + id = Column(Integer, primary_key=True, index=True) + flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) + + +class FlashcardArticleLink(Base): + __tablename__ = "flashcard_article_links" + __table_args__ = (UniqueConstraint("flashcard_id", "article_id", "article_section_id", name="uq_card_article_section"),) + + id = Column(Integer, primary_key=True, index=True) + flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False) + article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False) + article_section_id = Column(String(64), nullable=True) diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py new file mode 100644 index 0000000..97040e5 --- /dev/null +++ b/backend/app/routers/articles.py @@ -0,0 +1,342 @@ +"""Topic/article library with stable subsection links and card/question associations.""" +import re +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, field_validator +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.article import Article, QuestionArticleLink +from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink +from app.models.question import Question +from app.models.user import User +from app.services.quiz_builder import bank_question_predicate +from app.services.quiz_builder import category_breadcrumbs +from app.models.question_category import QuestionCategory +from app.utils.auth import get_current_user, require_moderator + +router = APIRouter() + +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class ArticleSection(BaseModel): + id: str + slug: str + title: str + content: str = "" + + +class ArticleWrite(BaseModel): + title: str + slug: str + summary: str | None = None + content: str | None = None + sections: list[ArticleSection] = [] + category_id: int | None = None + section_id: int | None = None + + @field_validator("slug") + @classmethod + def slug_shape(cls, value): + value = value.strip() + if not SLUG_RE.match(value) or len(value) > 120: + raise ValueError("Slug must be lowercase letters, digits and single hyphens (max 120)") + return value + + @field_validator("title") + @classmethod + def title_shape(cls, value): + value = value.strip() + if not value or len(value) > 300: + raise ValueError("Title is required (max 300 characters)") + return value + + +class ArticlePublish(BaseModel): + published: bool + + +class ArticleLinkIn(BaseModel): + question_id: int + section_id: str | None = None + + +def _validate_sections(sections: list[ArticleSection]): + ids, slugs = set(), set() + for section in sections: + if not re.fullmatch(r"[0-9a-f]{32}", section.id): + raise HTTPException(400, "Section IDs must be stable 32-character hex identifiers") + if not SLUG_RE.match(section.slug.strip()) or len(section.slug) > 120: + raise HTTPException(400, "Invalid section slug") + if not section.title.strip() or len(section.title) > 300: + raise HTTPException(400, "Section titles are required (max 300 characters)") + if section.id in ids or section.slug.strip() in slugs: + raise HTTPException(400, "Section IDs and slugs must be unique within an article") + ids.add(section.id) + slugs.add(section.slug.strip()) + + +def _section_ids(article): + return {section["id"] for section in (article.sections or [])} + + +def _article_json(article: Article) -> dict: + return { + "id": article.id, + "slug": article.slug, + "title": article.title, + "summary": article.summary, + "content": article.content, + "sections": article.sections, + "category_id": article.category_id, + "section_id": article.section_id, + "user_id": article.user_id, + "status": article.status, + "created_at": article.created_at, + "updated_at": article.updated_at, + } + + +@router.get("/") +def list_articles( + category_id: int | None = Query(None), + q: str | None = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Published articles for everyone; educators additionally see their own drafts.""" + query = db.query(Article) + if category_id: + query = query.filter(Article.category_id == category_id) + if q and q.strip(): + query = query.filter(Article.title.ilike(f"%{q.strip()}%")) + articles = query.order_by(Article.updated_at.desc()).all() + if not current_user.is_moderator: + articles = [a for a in articles if a.status == "published"] + return [_article_json(a) for a in articles] + + +@router.post("/") +def create_article( + data: ArticleWrite, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + _validate_sections(data.sections) + if data.category_id and not db.get(QuestionCategory, data.category_id): + raise HTTPException(400, "Category not found") + if db.query(Article.id).filter(Article.slug == data.slug).first(): + raise HTTPException(400, "Slug is already in use") + article = Article( + slug=data.slug, title=data.title, summary=data.summary, content=data.content, + sections=[section.model_dump() for section in data.sections], + category_id=data.category_id, section_id=data.section_id, + user_id=current_user.id, status="draft", + ) + db.add(article) + db.commit() + db.refresh(article) + return _article_json(article) + + +@router.get("/linked") +def articles_for_question( + question_id: int = Query(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Articles linked to a bank-visible question (published, or drafts for educators).""" + if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(current_user)).first(): + raise HTTPException(404, "Question not found") + rows = db.query(QuestionArticleLink, Article).join(Article, Article.id == QuestionArticleLink.article_id).filter( + QuestionArticleLink.question_id == question_id, + ).all() + result = [] + for link, article in rows: + if article.status != "published" and not current_user.is_moderator: + continue + result.append({**_article_json(article), "section_id": link.section_id}) + return result + + +@router.get("/{article_id}") +def get_article( + article_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id: + raise HTTPException(404, "Article not found") + data = _article_json(article) + categories = db.query(QuestionCategory).all() + data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else [] + return data + + +@router.get("/{article_id}/questions") +def article_questions( + article_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if article.status != "published" and not current_user.is_moderator: + raise HTTPException(404, "Article not found") + rows = db.query(QuestionArticleLink, Question).join(Question, Question.id == QuestionArticleLink.question_id).filter( + QuestionArticleLink.article_id == article_id, + ).all() + questions = [] + for link, question in rows: + if not current_user.is_moderator and not db.query(Question.id).filter( + Question.id == question.id, bank_question_predicate(current_user)).first(): + continue + questions.append({ + "question_id": question.id, "question_text": question.question_text, + "correct_answer": question.correct_answer, "explanation": question.explanation, + "section_id": link.section_id, + }) + return questions + + +@router.get("/{article_id}/cards") +def article_cards( + article_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if article.status != "published" and not current_user.is_moderator: + raise HTTPException(404, "Article not found") + rows = db.query(FlashcardArticleLink, Flashcard, FlashcardDeck).join( + Flashcard, Flashcard.id == FlashcardArticleLink.flashcard_id, + ).join(FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter( + FlashcardArticleLink.article_id == article_id, FlashcardDeck.deleted_at.is_(None), + ).all() + cards = [] + for link, card, deck in rows: + if not current_user.is_moderator and deck.user_id != current_user.id and not deck.is_shared: + continue + cards.append({"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title, + "front": card.front, "back": card.back, "article_section_id": link.article_section_id}) + return cards + + +@router.patch("/{article_id}") +def update_article( + article_id: int, + data: ArticleWrite, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if not (current_user.is_moderator or article.user_id == current_user.id): + raise HTTPException(403, "Not your article") + _validate_sections(data.sections) + if data.category_id and not db.get(QuestionCategory, data.category_id): + raise HTTPException(400, "Category not found") + if db.query(Article.id).filter(Article.slug == data.slug, Article.id != article.id).first(): + raise HTTPException(400, "Slug is already in use") + article.slug, article.title, article.summary, article.content = data.slug, data.title, data.summary, data.content + article.sections = [section.model_dump() for section in data.sections] + article.category_id, article.section_id = data.category_id, data.section_id + # Remediate links whose section was removed; whole-article links survive renames. + kept = _section_ids(article) + db.query(QuestionArticleLink).filter( + QuestionArticleLink.article_id == article.id, + QuestionArticleLink.section_id.isnot(None), + QuestionArticleLink.section_id.notin_(kept) if kept else True, + ).delete(synchronize_session=False) + db.query(FlashcardArticleLink).filter( + FlashcardArticleLink.article_id == article.id, + FlashcardArticleLink.article_section_id.isnot(None), + FlashcardArticleLink.article_section_id.notin_(kept) if kept else True, + ).delete(synchronize_session=False) + db.commit() + db.refresh(article) + return _article_json(article) + + +@router.delete("/{article_id}", status_code=204) +def delete_article( + article_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if not (current_user.is_moderator or article.user_id == current_user.id): + raise HTTPException(403, "Not your article") + db.delete(article) + db.commit() + + +@router.post("/{article_id}/publish") +def publish_article( + article_id: int, + data: ArticlePublish, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + article.status = "published" if data.published else "draft" + db.commit() + return _article_json(article) + + +@router.put("/{article_id}/links") +def link_question( + article_id: int, + data: ArticleLinkIn, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if data.section_id is not None and data.section_id not in _section_ids(article): + raise HTTPException(400, "Section not found in this article") + if not db.query(Question.id).filter(Question.id == data.question_id, bank_question_predicate(current_user)).first(): + raise HTTPException(404, "Question not found") + existing = db.query(QuestionArticleLink).filter_by( + question_id=data.question_id, article_id=article_id, section_id=data.section_id, + ).first() + if existing: + return {"linked": False} + db.add(QuestionArticleLink(question_id=data.question_id, article_id=article_id, + section_id=data.section_id, user_id=current_user.id)) + db.commit() + return {"linked": True} + + +@router.delete("/{article_id}/links/{question_id}", status_code=204) +def unlink_question( + article_id: int, + question_id: int, + section_id: str | None = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + db.query(QuestionArticleLink).filter_by( + question_id=question_id, article_id=article_id, section_id=section_id, + ).delete(synchronize_session=False) + db.commit() + + +@router.post("/{article_id}/section-id") +def new_section_id(): + """Clients may mint stable section IDs offline; this helper exists for parity.""" + return {"id": uuid.uuid4().hex} diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index 2b08fa3..c2dee5e 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -7,9 +7,15 @@ from sqlalchemy import or_, text as sa_text from sqlalchemy.orm import Session from app.database import get_db -from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating +from app.models.article import Article +from app.models.flashcard import ( + FlashcardDeck, Flashcard, FlashcardDeckRating, + FlashcardQuestionLink, FlashcardArticleLink, +) +from app.models.question import Question from app.models.section import Section from app.models.user import User +from app.services.quiz_builder import bank_question_predicate from app.utils.auth import get_current_user, require_moderator router = APIRouter() @@ -510,3 +516,179 @@ def delete_flashcard( if deck and deck.card_count > 0: deck.card_count -= 1 db.commit() + + +# ── Manual cards and question/article associations ──────────────────── + +class CardCreate(BaseModel): + front: str + back: str + image_path: str | None = None + + +class CardQuestionLinkIn(BaseModel): + question_id: int + + +class CardArticleLinkIn(BaseModel): + article_id: int + article_section_id: str | None = None + + +@router.post("/decks/{deck_id}/cards") +def create_flashcard_manually( + deck_id: int, + data: CardCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Add a single card to an existing deck. Owner or admin.""" + deck = _own_deck_or_404(deck_id, current_user, db) + if not data.front.strip() or not data.back.strip(): + raise HTTPException(status_code=400, detail="Both sides are required") + card = Flashcard(deck_id=deck.id, front=data.front.strip(), back=data.back.strip(), + image_path=(data.image_path or None) or None) + db.add(card) + deck.card_count = (deck.card_count or 0) + 1 + db.commit() + db.refresh(card) + return {"id": card.id, "deck_id": deck.id, "front": card.front, "back": card.back, "image_path": card.image_path} + + +def _own_card_or_404(card_id: int, current_user: User, db: Session) -> Flashcard: + card = db.get(Flashcard, card_id) + if not card: + raise HTTPException(status_code=404, detail="Card not found") + deck = db.get(FlashcardDeck, card.deck_id) + if deck and deck.user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not your card") + return card + + +@router.get("/cards/{card_id}/links") +def list_card_links( + card_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Questions and articles associated with a card (access follows the deck).""" + card = db.get(Flashcard, card_id) + if not card: + raise HTTPException(status_code=404, detail="Card not found") + deck = db.get(FlashcardDeck, card.deck_id) + if deck and deck.user_id != current_user.id and not deck.is_shared and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not your card") + questions = db.query(Question.id, Question.question_text).join( + FlashcardQuestionLink, FlashcardQuestionLink.question_id == Question.id, + ).filter(FlashcardQuestionLink.flashcard_id == card_id).all() + articles = db.query(Article.id, Article.title, Article.status, FlashcardArticleLink.article_section_id).join( + FlashcardArticleLink, FlashcardArticleLink.article_id == Article.id, + ).filter(FlashcardArticleLink.flashcard_id == card_id).all() + return { + "questions": [{"id": qid, "question_text": text} for qid, text in questions], + "articles": [{"id": aid, "title": title, "status": status, "article_section_id": sid} + for aid, title, status, sid in articles], + } + + +@router.put("/cards/{card_id}/links/question") +def link_card_question( + card_id: int, + data: CardQuestionLinkIn, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _own_card_or_404(card_id, current_user, db) + if not db.query(Question.id).filter(Question.id == data.question_id, bank_question_predicate(current_user)).first(): + raise HTTPException(status_code=404, detail="Question not found") + if db.query(FlashcardQuestionLink.id).filter_by(flashcard_id=card_id, question_id=data.question_id).first(): + return {"linked": False} + db.add(FlashcardQuestionLink(flashcard_id=card_id, question_id=data.question_id)) + db.commit() + return {"linked": True} + + +@router.delete("/cards/{card_id}/links/question/{question_id}", status_code=204) +def unlink_card_question( + card_id: int, + question_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _own_card_or_404(card_id, current_user, db) + db.query(FlashcardQuestionLink).filter_by(flashcard_id=card_id, question_id=question_id).delete(synchronize_session=False) + db.commit() + + +@router.put("/cards/{card_id}/links/article") +def link_card_article( + card_id: int, + data: CardArticleLinkIn, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _own_card_or_404(card_id, current_user, db) + article = db.get(Article, data.article_id) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + if data.article_section_id is not None and data.article_section_id not in { + section["id"] for section in (article.sections or [])}: + raise HTTPException(status_code=400, detail="Section not found in this article") + if db.query(FlashcardArticleLink.id).filter_by( + flashcard_id=card_id, article_id=data.article_id, article_section_id=data.article_section_id).first(): + return {"linked": False} + db.add(FlashcardArticleLink(flashcard_id=card_id, article_id=data.article_id, + article_section_id=data.article_section_id)) + db.commit() + return {"linked": True} + + +@router.delete("/cards/{card_id}/links/article/{article_id}", status_code=204) +def unlink_card_article( + card_id: int, + article_id: int, + article_section_id: str | None = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _own_card_or_404(card_id, current_user, db) + db.query(FlashcardArticleLink).filter_by( + flashcard_id=card_id, article_id=article_id, article_section_id=article_section_id, + ).delete(synchronize_session=False) + db.commit() + + +@router.get("/cards/linked") +def cards_for_target( + question_id: int | None = Query(None), + article_id: int | None = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Cards linked to a question or article; results follow deck sharing/ownership.""" + if (question_id is None) == (article_id is None): + raise HTTPException(400, "Provide exactly one of question_id or article_id") + if question_id is not None and not db.query(Question.id).filter( + Question.id == question_id, bank_question_predicate(current_user)).first(): + raise HTTPException(404, "Question not found") + query = db.query(Flashcard, FlashcardDeck).join( + FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id, + ).filter(FlashcardDeck.deleted_at.is_(None)) + if question_id is not None: + query = query.join(FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).filter( + FlashcardQuestionLink.question_id == question_id) + else: + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + if article.status != "published" and not current_user.is_moderator: + raise HTTPException(404, "Article not found") + query = query.join(FlashcardArticleLink, FlashcardArticleLink.flashcard_id == Flashcard.id).filter( + FlashcardArticleLink.article_id == article_id) + cards = [] + for card, deck in query.all(): + if not current_user.is_moderator and deck.user_id != current_user.id and not deck.is_shared: + continue + cards.append({"id": card.id, "deck_id": deck.id, "deck_title": deck.title, + "front": card.front, "back": card.back, "image_path": card.image_path}) + return cards diff --git a/backend/tests/test_articles_cards.py b/backend/tests/test_articles_cards.py new file mode 100644 index 0000000..8ce98eb --- /dev/null +++ b/backend/tests/test_articles_cards.py @@ -0,0 +1,160 @@ +"""Article library and card association routes on disposable SQLite; no network/AI.""" +import unittest + +import test_quiz_builder as fixtures +from app.models.article import Article, QuestionArticleLink +from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardQuestionLink, FlashcardArticleLink +from app.models.question import Question +from app.routers import articles, flashcards + + +class ArticlesCardsTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(articles.router, prefix='/articles') + self.client.app.include_router(flashcards.router, prefix='/flashcards') + + def tearDown(self): + self.bank.tearDown() + + def create(self, **overrides): + payload = {"title": "Topic article", "slug": "topic-article", + "summary": "Summary", "content": "Intro", + "sections": [{"id": "a" * 32, "slug": "first-section", "title": "First section", "content": "Body"}], + **overrides} + return self.client.post('/articles/', json=payload) + + def test_article_status_visibility_and_editing(self): + self.bank.user = self.bank.mod + article = self.create().json() + self.assertIn('a' * 32, [s['id'] for s in article['sections']]) + self.bank.user = self.bank.owner + self.assertEqual(self.client.get('/articles/').json(), []) + self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404) + self.bank.user = self.bank.mod + self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) + self.bank.user = self.bank.owner + listing = self.client.get('/articles/').json() + self.assertEqual([a['id'] for a in listing], [article['id']]) + detail = self.client.get(f"/articles/{article['id']}").json() + self.assertEqual(detail['category_breadcrumbs'], []) + self.bank.user = self.bank.peer + self.assertEqual(self.client.patch(f"/articles/{article['id']}", json={ + "title": "Topic article", "slug": "topic-article", "content": "Intro", "sections": []}).status_code, 403) + self.assertEqual(self.client.delete(f"/articles/{article['id']}").status_code, 403) + self.bank.user = self.bank.mod + for payload in [{"title": " ", "slug": "x"}, {"title": "T", "slug": "Bad Slug"}, + {"title": "T", "slug": "ok", "sections": [{"id": "short", "slug": "s", "title": "S", "content": ""}]}, + {"title": "T", "slug": "ok", "sections": [{"id": "a" * 32, "slug": "dup", "title": "A"}, + {"id": "b" * 32, "slug": "dup", "title": "B"}]}]: + self.assertIn(self.create(**payload).status_code, (400, 422), payload) + self.assertEqual(self.create(slug='topic-article').status_code, 400) + self.assertEqual(self.client.post(f"/articles/{article['id']}/publish", json={'published': False}).status_code, 200) + self.bank.user = self.bank.owner + self.assertEqual(self.client.get('/articles/').json(), []) + self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404) + + def test_section_stability_remediation_and_question_links(self): + self.bank.user = self.bank.mod + article = self.create().json() + section_id = article['sections'][0]['id'] + link = self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}) + self.assertEqual(link.status_code, 200, link.text) + self.assertEqual(link.json()['linked'], True) + self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}).json()['linked'], False) + self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': 'f' * 32}).status_code, 400) + self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 4, 'section_id': section_id}).status_code, 404) + self.bank.user = self.bank.owner + self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1}).status_code, 403) + self.bank.user = self.bank.mod + self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) + self.bank.user = self.bank.owner + self.assertEqual(self.client.get('/articles/linked', params={'question_id': 1}).json()[0]['section_id'], section_id) + # Whole-article link survives section renames/removal of OTHER sections. + self.bank.user = self.bank.mod + self.client.put(f"/articles/{article['id']}/links", json={'question_id': 2}) + patch = self.client.patch(f"/articles/{article['id']}", json={ + "title": "Renamed", "slug": "renamed", "content": "Intro", + "sections": [{"id": section_id, "slug": "renamed-section", "title": "Renamed section", "content": "New body"}]}) + self.assertEqual(patch.status_code, 200, patch.text) + self.assertEqual(self.bank.db.query(QuestionArticleLink).filter_by(article_id=article['id']).count(), 2) + # Removing the section remediates only that section link. + self.client.patch(f"/articles/{article['id']}", json={ + "title": "Renamed", "slug": "renamed", "content": "Intro", + "sections": [{"id": "c" * 32, "slug": "other", "title": "Other", "content": ""}]}) + links = self.bank.db.query(QuestionArticleLink).filter_by(article_id=article['id']).all() + self.assertEqual({(l.question_id, l.section_id) for l in links}, {(2, None)}) + self.bank.user = self.bank.peer + linked = self.client.get('/articles/linked', params={'question_id': 2}).json() + self.assertEqual([l['section_id'] for l in linked], [None]) + published_questions = self.client.get(f"/articles/{article['id']}/questions") + self.assertEqual(published_questions.status_code, 200) + self.assertEqual([q['question_id'] for q in published_questions.json()], [2]) + + def test_article_questions_visibility_and_publish(self): + self.bank.user = self.bank.mod + article = self.create().json() + self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1}) + self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) + self.bank.user = self.bank.owner + questions = self.client.get(f"/articles/{article['id']}/questions").json() + self.assertEqual([q['question_id'] for q in questions], [1]) + self.bank.db.get(Question, 1).is_shared = 0 + self.bank.db.commit() + self.bank.user = self.bank.peer + self.assertEqual(self.client.get(f"/articles/{article['id']}/questions").json(), []) + self.bank.user = self.bank.mod + self.assertEqual(len(self.client.get(f"/articles/{article['id']}/questions").json()), 1) + + def test_manual_cards_links_and_target_listing(self): + deck = FlashcardDeck(user_id=3, title='Educator deck', is_shared=0) + private = FlashcardDeck(user_id=2, title='Private deck', is_shared=0) + self.bank.db.add_all([deck, private]) + self.bank.db.flush() + self.bank.db.commit() + self.bank.user = self.bank.mod + response = self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'Front', 'back': 'Back'}) + self.assertEqual(response.status_code, 200, response.text) + card = response.json() + self.assertEqual(self.bank.db.get(FlashcardDeck, deck.id).card_count, 1) + self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': '', 'back': ''}).status_code, 400) + article = self.create().json() + self.bank.user = self.bank.owner + self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'F', 'back': 'B'}).status_code, 403) + self.bank.user = self.bank.mod + self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 1}).status_code, 200) + self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 4}).status_code, 404) + self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article", + json={'article_id': article['id'], 'article_section_id': 'f' * 32}).status_code, 400) + self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article", + json={'article_id': article['id']}).status_code, 200) + links = self.client.get(f"/flashcards/cards/{card['id']}/links").json() + self.assertEqual([q['id'] for q in links['questions']], [1]) + self.assertEqual([a['id'] for a in links['articles']], [article['id']]) + self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) + # Before sharing, peers cannot see links or linked cards; mutating links stays owner/admin-only. + self.bank.user = self.bank.peer + self.assertEqual(self.client.get('/flashcards/cards/linked', params={'question_id': 1}).json(), []) + self.assertEqual(self.client.get(f"/flashcards/cards/{card['id']}/links").status_code, 403) + self.assertEqual(self.client.delete(f"/flashcards/cards/{card['id']}/links/question/1").status_code, 403) + # Sharing the deck grants read access to linked content. + self.bank.db.get(FlashcardDeck, deck.id).is_shared = 1 + self.bank.db.commit() + self.bank.user = self.bank.owner + self.assertEqual([c['id'] for c in self.client.get('/flashcards/cards/linked', params={'question_id': 1}).json()], [card['id']]) + self.assertEqual([c['id'] for c in self.client.get('/flashcards/cards/linked', params={'article_id': article['id']}).json()], [card['id']]) + self.bank.user = self.bank.mod + self.assertEqual(self.client.delete(f"/flashcards/cards/{card['id']}/links/question/1").status_code, 204) + self.assertEqual(self.bank.db.query(FlashcardQuestionLink).count(), 0) + self.client.delete(f"/flashcards/cards/{card['id']}/links/article/{article['id']}") + self.assertEqual(self.bank.db.query(FlashcardArticleLink).count(), 0) + self.assertEqual(self.client.get('/flashcards/cards/linked', params={'question_id': 1, 'article_id': 1}).status_code, 400) + # Deleting an article cascades its links. + self.client.delete(f"/articles/{article['id']}") + self.assertEqual(self.bank.db.query(QuestionArticleLink).count(), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tests/test_articles_migration.py b/backend/tests/test_articles_migration.py new file mode 100644 index 0000000..76f6c94 --- /dev/null +++ b/backend/tests/test_articles_migration.py @@ -0,0 +1,44 @@ +"""Offline PostgreSQL DDL checks for the articles/cards migration; no database access.""" +import io +import os +import unittest +from pathlib import Path +from unittest.mock import patch + +os.environ['DATABASE_URL'] = 'sqlite:///:memory:' +from alembic import command +from alembic.config import Config +from alembic.script import ScriptDirectory + + +class ArticlesMigrationTests(unittest.TestCase): + def test_models_register_fresh_and_offline_upgrade_downgrade(self): + import subprocess + import sys + result = subprocess.run([sys.executable, '-c', + "import app.models; from app.database import Base; " + "assert {'articles','question_article_links','flashcard_question_links','flashcard_article_links'} <= set(Base.metadata.tables)"], + capture_output=True, text=True, env={**os.environ, 'DATABASE_URL': 'sqlite:///:memory:'}) + self.assertEqual(result.returncode, 0, result.stderr) + output = io.StringIO() + config = Config(output_buffer=output) + config.set_main_option('script_location', str(Path(__file__).resolve().parents[1] / 'alembic')) + scripts = ScriptDirectory.from_config(config) + self.assertEqual(len(scripts.get_heads()), 1) + self.assertEqual(scripts.get_revision('e8d4f1a27c93').down_revision, 'd94a26b8f302') + with patch.dict(os.environ, {'DATABASE_URL': 'postgresql://unused@127.0.0.1/offline_only'}): + command.upgrade(config, 'd94a26b8f302:e8d4f1a27c93', sql=True) + sql = output.getvalue() + for table in ('articles', 'question_article_links', 'flashcard_question_links', 'flashcard_article_links'): + self.assertIn(f'CREATE TABLE IF NOT EXISTS {table}', sql) + self.assertIn('UNIQUE (question_id, article_id, section_id)', sql) + self.assertIn('ON DELETE CASCADE', sql) + output.seek(0) + output.truncate() + command.downgrade(config, 'e8d4f1a27c93:d94a26b8f302', sql=True) + for table in ('flashcard_article_links', 'flashcard_question_links', 'question_article_links', 'articles'): + self.assertIn(f'DROP TABLE IF EXISTS {table}', output.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/quiz-revamp-progress.md b/docs/quiz-revamp-progress.md index 5b5f20f..86ee088 100644 --- a/docs/quiz-revamp-progress.md +++ b/docs/quiz-revamp-progress.md @@ -67,6 +67,14 @@ Verification: 37 backend tests in the exact deployed image (new card-moderator, Known boundaries: only course_files/course_thumbnails/scorm/certificates retain legacy LMS policy; already-cached public responses cannot be recalled by new no-store headers; downloaded offline content likewise. Privacy work is not committed or deployed yet. +## Milestone 3 — linked articles and cards (under verification) + +Added an educator-authored topic library with draft/published states, stable section IDs (heading renames never break links; removed sections remediate their links), category breadcrumbs and whole-article/section question links. Articles reuse the Milkdown rich editor; reading is side-by-side on desktop with a drawer on mobile. External references stay links. Empty content is honestly labeled. + +Reused the existing flashcard system and added manual card creation plus card↔question and card↔article associations. Linked content follows bank visibility and deck ownership/sharing; mutation stays owner/admin-only. New migration `e8d4f1a27c93` (after `d94a26b8f302`) must run before deployment. + +Verification: 42 backend tests in the deployed image, 72 frontend tests across 12 suites and production build, plus a real isolated PostgreSQL upgrade/repeat/downgrade/re-upgrade with defaults and FK enforcement. Independent review and browser checks pending. + ## Next Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ee4d2b1..4de519e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -26,6 +26,8 @@ const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage')) const NotFoundPage = lazy(() => import('./pages/NotFoundPage')) const LandingPage = lazy(() => import('./pages/LandingPage')) const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) +const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) +const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage')) const CoursesPage = lazy(() => import('./pages/CoursesPage')) const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage')) @@ -89,6 +91,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index f031d35..2f71c66 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -92,6 +92,7 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/', label: 'Dashboard' }, { to: '/quizzes', label: 'Quizzes' }, { to: '/question-bank', label: 'Question Bank' }, + { to: '/articles', label: 'Reading' }, { to: '/flashcards', label: 'Flashcards' }, { to: '/courses', label: 'Courses' }, { to: '/settings', label: '⚙ Settings' }, diff --git a/frontend/src/components/QuestionReadingLinks.jsx b/frontend/src/components/QuestionReadingLinks.jsx new file mode 100644 index 0000000..84d6e39 --- /dev/null +++ b/frontend/src/components/QuestionReadingLinks.jsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../api/client' + +export default function QuestionReadingLinks({ questionId }) { + const [links, setLinks] = useState(null) + useEffect(() => { + if (!questionId) { setLinks([]); return } + api.get('/articles/linked', { params: { question_id: questionId } }) + .then(res => setLinks(res.data)).catch(() => setLinks([])) + }, [questionId]) + if (!links || links.length === 0) return null + return ( +
+ Topic reading +
    + {links.map(link => ( +
  • + + {link.title} + {link.section_id && link.sections?.find(s => s.id === link.section_id)?.title + ? ` — ${link.sections.find(s => s.id === link.section_id).title}` : ''} + +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css new file mode 100644 index 0000000..34217dc --- /dev/null +++ b/frontend/src/pages/ArticlesPage.css @@ -0,0 +1,48 @@ +.articles-page, .article-page { max-width: 1080px; margin: 0 auto; padding: 4px 0 24px; } +.articles-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } +.articles-header h1, .article-header h1 { margin: 0 0 4px; font-size: 1.5rem; } +.articles-subtitle { margin: 0; color: var(--text-muted); font-size: 0.88rem; } +.articles-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.articles-toolbar .input { max-width: 320px; } +.articles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } +.article-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 16px; text-decoration: none; color: var(--text); transition: box-shadow .15s ease, transform .15s ease; } +.article-card:hover { box-shadow: 0 8px 24px rgba(2, 70, 145, .12); transform: translateY(-2px); } +.article-card h3 { margin: 0 0 6px; font-size: 1.02rem; } +.article-card p { margin: 0 0 8px; color: var(--text-muted); font-size: .85rem; } +.article-card-meta { color: var(--text-muted); font-size: .75rem; } +.article-status-draft { font-size: .65rem; font-weight: 700; text-transform: uppercase; color: #92400e; background: #fef3c7; padding: 2px 7px; border-radius: 10px; margin-left: 6px; vertical-align: middle; } +.breadcrumbs { font-size: .8rem; color: var(--text-muted); margin-bottom: 8px; } +.breadcrumbs a { color: var(--primary); text-decoration: none; } +.article-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } +.article-header-actions { display: flex; gap: 8px; } +.article-layout { display: grid; grid-template-columns: 240px 1fr; gap: 20px; align-items: start; } +.article-sections { position: sticky; top: 80px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 14px; } +.article-sections h4 { margin: 0 0 8px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); } +.article-sections ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.section-link { width: 100%; text-align: left; background: none; border: none; border-radius: 6px; padding: 7px 10px; font-size: .85rem; color: var(--text); cursor: pointer; } +.section-link:hover { background: var(--hover, #eef4fb); } +.section-link.active { background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 600; } +.article-content { min-width: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 22px 26px; } +.article-content h2 { margin-top: 0; } +.article-content img { max-width: 100%; border-radius: 8px; } +.article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; } +.article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; } +.article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; } +.article-drawer-toggle { display: none; margin-bottom: 10px; } +.article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; } +.article-linked h3 { margin: 0 0 10px; font-size: 1rem; } +.linked-question { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 6px; } +.linked-question-text { font-weight: 600; } +.linked-answer { color: var(--correct-fg); font-size: .88rem; } +.linked-explanation { color: var(--text-muted); font-size: .86rem; } +.linked-section-tag { font-size: .72rem; color: var(--text-muted); } +.linked-card { display: flex; justify-content: space-between; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; flex-wrap: wrap; } +.article-edit { display: flex; flex-direction: column; gap: 8px; } +.article-edit .form-label { margin-top: 6px; } +@media (max-width: 820px) { + .article-layout { grid-template-columns: 1fr; } + .article-sections { position: static; display: none; } + .article-sections.open { display: block; } + .article-drawer-toggle { display: inline-block; } + .article-content { padding: 16px; } +} diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx new file mode 100644 index 0000000..d052f99 --- /dev/null +++ b/frontend/src/pages/ArticlesPage.jsx @@ -0,0 +1,324 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' +import api from '../api/client' +import { useAuth } from '../context/AuthContext' +import RichEditor from '../components/RichEditor' +import { markdownImageUrl } from '../utils/uploads' +import './ArticlesPage.css' + +const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('') + +function Markdown({ children, attemptId }) { + return ( + , + a: ({ node, ...props }) => , + }}> + {children || ''} + + ) +} + +function DraftBadge({ status }) { + if (status === 'published') return null + return Draft +} + +export default function ArticlesPage() { + const { user } = useAuth() + const [articles, setArticles] = useState([]) + const [categories, setCategories] = useState([]) + const [categoryId, setCategoryId] = useState('') + const [query, setQuery] = useState('') + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [title, setTitle] = useState('') + const [slug, setSlug] = useState('') + const [error, setError] = useState('') + const navigate = useNavigate() + + const load = useCallback(() => { + const params = {} + if (categoryId) params.category_id = categoryId + if (query.trim()) params.q = query.trim() + api.get('/articles', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false)) + }, [categoryId, query]) + + useEffect(() => { load() }, [load]) + useEffect(() => { + api.get('/question-categories').then(res => setCategories(res.data)).catch(() => {}) + }, []) + + const create = async () => { + setError('') + if (!title.trim() || !slug.trim()) { setError('Title and slug are required'); return } + try { + const res = await api.post('/articles', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] }) + setShowCreate(false) + navigate(`/articles/${res.data.id}?edit=1`) + } catch (err) { + setError(err.response?.data?.detail || 'Could not create article') + } + } + + return ( +
+
+
+

Topic reading

+

Educator-reviewed articles linked to questions and cards.

+
+ {user?.is_moderator && ( + + )} +
+ {showCreate && ( +
+

New article

+ + setTitle(e.target.value)} placeholder="e.g. Febrile seizures" /> + + setSlug(e.target.value)} placeholder="febrile-seizures" /> + {error &&
{error}
} +
+ + +
+
+ )} +
+ setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" /> + +
+ {loading ?
: articles.length === 0 ? ( +
No articles yet. Educators add and refine articles gradually.
+ ) : ( +
+ {articles.map(article => ( + +

{article.title}

+ {article.summary &&

{article.summary}

} + {article.sections?.length || 0} sections + + ))} +
+ )} +
+ ) +} + +export function ArticlePage() { + const { user } = useAuth() + const { id } = useParams() + const [searchParams, setSearchParams] = useSearchParams() + const [article, setArticle] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [activeSection, setActiveSection] = useState('') + const [drawerOpen, setDrawerOpen] = useState(false) + const [questions, setQuestions] = useState([]) + const [cards, setCards] = useState([]) + const [editing, setEditing] = useState(searchParams.get('edit') === '1') + const [saving, setSaving] = useState(false) + const [form, setForm] = useState(null) + const [linkQuestionId, setLinkQuestionId] = useState('') + const [linkSection, setLinkSection] = useState('') + const [linkError, setLinkError] = useState('') + const navigate = useNavigate() + + const load = useCallback(() => { + api.get(`/articles/${id}`).then(res => { + setArticle(res.data) + if (!activeSection && res.data.sections?.length) setActiveSection(res.data.sections[0].id) + setForm({ title: res.data.title, slug: res.data.slug, summary: res.data.summary || '', content: res.data.content || '', sections: res.data.sections || [] }) + }).catch(err => setError(err.response?.status === 404 ? 'Article not found' : 'Could not load article')).finally(() => setLoading(false)) + api.get(`/articles/${id}/questions`).then(res => setQuestions(res.data)).catch(() => setQuestions([])) + api.get(`/articles/${id}/cards`).then(res => setCards(res.data)).catch(() => setCards([])) + }, [id, activeSection]) + + useEffect(() => { load() }, [load]) + + const section = article?.sections?.find(s => s.id === activeSection) + const chosenSection = searchParams.get('section') || '' + + const save = async (publish = null) => { + setSaving(true) + setError('') + try { + const payload = { ...form } + const res = await api.patch(`/articles/${id}`, payload) + setArticle(res.data) + if (publish !== null) { + await api.post(`/articles/${id}/publish`, { published: publish }) + setArticle(prev => ({ ...prev, status: publish ? 'published' : 'draft' })) + } + setEditing(false) + setSearchParams({}) + load() + } catch (err) { + setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not save') + } finally { setSaving(false) } + } + + const linkQuestion = async () => { + setLinkError('') + if (!linkQuestionId.trim()) return + try { + await api.put(`/articles/${id}/links`, { question_id: parseInt(linkQuestionId, 10), section_id: linkSection || null }) + setLinkQuestionId('') + load() + } catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not link question') } + } + + const unlinkQuestion = async (questionId, linkSectionId) => { + await api.delete(`/articles/${id}/links/${questionId}`, { params: linkSectionId ? { section_id: linkSectionId } : {} }) + load() + } + + if (loading) return
+ if (error && !article) return
{error}
+ if (!article) return null + + const canEdit = user?.is_moderator || article.user_id === user?.id + + return ( +
+ +
+

{article.title}

+
+ {canEdit && !editing && ( + <> + + + + )} + {editing && } +
+
+ {error &&
{error}
} + + {editing && form ? ( +
+ + setForm(f => ({ ...f, title: e.target.value }))} /> + + setForm(f => ({ ...f, slug: e.target.value }))} /> + +