feat: one settings page, and comments removed
Settings linked to a second dashboard with its own tab bar and its own visual language. The admin sections are rendered in Settings now, under headings that say who they are for — You, Content, The site — and each has its own address, so People, AI models, Safety and Search are links. /admin redirects into Settings for anyone who bookmarked it. AdminPage takes a `section` prop and drops its tab row when embedded; it is loaded lazily, so it is not in a learner's download. Comments are gone: router, model, table and the half of the test file that covered them. They were a discussion thread nobody was obliged to answer, and feedback replaced them with a message addressed to whoever maintains the question. The table was empty, so nothing was lost — verified before dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
c20b1e678f
commit
2c5d3c67b4
12 changed files with 228 additions and 272 deletions
37
backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py
Normal file
37
backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Drop the comments table.
|
||||
|
||||
Comments were a discussion thread under every article and question. Nothing
|
||||
read them into anyone's work, so they collected opinion nobody was obliged to
|
||||
answer. Feedback replaced them: a message addressed to whoever maintains the
|
||||
question, which an educator resolves, replies to, or deletes.
|
||||
|
||||
The table was empty when this was written, so nothing is lost. The downgrade
|
||||
recreates the shape but cannot recreate rows.
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e5f6a7b8c9d0"
|
||||
down_revision = "d4e5f6a7b8c9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("comments")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"comments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("article_id", sa.Integer(), sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(), server_default="pending"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
|
@ -13,7 +13,7 @@ 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, exams
|
||||
from app.routers import access
|
||||
from app.routers import feedback
|
||||
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
|
||||
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode
|
||||
from app.utils.auth import get_password_hash
|
||||
|
||||
|
||||
|
|
@ -619,7 +619,6 @@ app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"])
|
|||
app.include_router(exams.router, prefix="/api/exams", tags=["exams"])
|
||||
app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"])
|
||||
app.include_router(media.router, prefix="/api/media", tags=["media"])
|
||||
app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
|
||||
app.include_router(share.router, prefix="/api/share", tags=["share"])
|
||||
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
|
||||
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from app.models.favorite import Favorite
|
|||
from app.models.user_note import UserNote
|
||||
from app.models.lab_reference import LabReference, LabReferenceCardLink
|
||||
from app.models.article import Article, QuestionArticleLink
|
||||
from app.models.comment import Comment
|
||||
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.collection import UserCollection, UserCollectionQuestion
|
||||
|
|
@ -29,7 +28,6 @@ __all__ = [
|
|||
"LabReferenceCardLink",
|
||||
"Article",
|
||||
"QuestionArticleLink",
|
||||
"Comment",
|
||||
"FlashcardDeck",
|
||||
"Flashcard",
|
||||
"FlashcardDeckRating",
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""Moderated discussion comment on an article or a question."""
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=True)
|
||||
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
content = Column(Text, nullable=False)
|
||||
status = Column(String, default="pending") # pending | approved | rejected
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User")
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
"""Moderated comments on articles and questions."""
|
||||
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
|
||||
from app.models.comment import Comment
|
||||
from app.models.question import Question
|
||||
from app.models.user import User
|
||||
from app.services.quiz_builder import bank_question_predicate
|
||||
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
article_id: int | None = None
|
||||
question_id: int | None = None
|
||||
content: str
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def content_shape(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Comment cannot be empty")
|
||||
if len(value) > 2000:
|
||||
raise ValueError("Comment is too long (max 2000 characters)")
|
||||
return value
|
||||
|
||||
|
||||
class CommentModerate(BaseModel):
|
||||
status: str
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def status_shape(cls, value):
|
||||
if value not in ("pending", "approved", "rejected"):
|
||||
raise ValueError("Invalid moderation status")
|
||||
return value
|
||||
|
||||
|
||||
def _target(db: Session, article_id: int | None, question_id: int | None, user: User):
|
||||
if (article_id is None) == (question_id is None):
|
||||
raise HTTPException(400, "Provide exactly one of article_id or question_id")
|
||||
if article_id is not None:
|
||||
article = db.get(Article, article_id)
|
||||
if not article:
|
||||
raise HTTPException(404, "Article not found")
|
||||
if article.status != "published" and not user.is_moderator:
|
||||
raise HTTPException(404, "Article not found")
|
||||
return "article_id", article
|
||||
question = db.get(Question, question_id)
|
||||
if not question or not db.query(Question.id).filter(
|
||||
Question.id == question_id, bank_question_predicate(user)).first():
|
||||
raise HTTPException(404, "Question not found")
|
||||
return "question_id", question
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_comment(
|
||||
data: CommentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
target_field, _ = _target(db, data.article_id, data.question_id, current_user)
|
||||
check_rate_limit(
|
||||
key=f"comments:daily:{current_user.id}",
|
||||
max_calls=20,
|
||||
window_seconds=86400,
|
||||
detail="You've reached today's comment limit. Try again tomorrow.",
|
||||
user=current_user,
|
||||
)
|
||||
comment = Comment(article_id=data.article_id, question_id=data.question_id,
|
||||
user_id=current_user.id, content=data.content, status="pending")
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
return _json(db, comment, current_user)
|
||||
|
||||
|
||||
def _json(db: Session, comment: Comment, user: User):
|
||||
author = db.get(User, comment.user_id) if comment.user_id else None
|
||||
return {
|
||||
"id": comment.id,
|
||||
"article_id": comment.article_id,
|
||||
"question_id": comment.question_id,
|
||||
"user_id": comment.user_id,
|
||||
"author_name": author.name if author else "Unknown",
|
||||
"content": comment.content,
|
||||
"status": comment.status,
|
||||
"created_at": comment.created_at,
|
||||
"own": comment.user_id == user.id,
|
||||
"can_moderate": user.is_moderator,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_comments(
|
||||
article_id: int | None = Query(None),
|
||||
question_id: int | None = Query(None),
|
||||
limit: int = Query(20, le=100),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
target_field, target = _target(db, article_id, question_id, current_user)
|
||||
target_id = getattr(target, "id")
|
||||
query = db.query(Comment).filter(getattr(Comment, target_field) == target_id).filter(
|
||||
(Comment.status == "approved") | (Comment.user_id == current_user.id),
|
||||
)
|
||||
total = query.count()
|
||||
comments = query.order_by(Comment.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
|
||||
|
||||
|
||||
@router.get("/moderation")
|
||||
def list_pending_comments(
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
query = db.query(Comment).filter(Comment.status == "pending")
|
||||
total = query.count()
|
||||
comments = query.order_by(Comment.created_at.asc()).offset(offset).limit(limit).all()
|
||||
return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
|
||||
|
||||
|
||||
@router.patch("/{comment_id}")
|
||||
def moderate_comment(
|
||||
comment_id: int,
|
||||
data: CommentModerate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
comment = db.get(Comment, comment_id)
|
||||
if not comment:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
comment.status = data.status
|
||||
db.commit()
|
||||
return _json(db, comment, current_user)
|
||||
|
||||
|
||||
@router.delete("/{comment_id}", status_code=204)
|
||||
def delete_comment(
|
||||
comment_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
comment = db.get(Comment, comment_id)
|
||||
if not comment:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if comment.user_id != current_user.id and not current_user.is_moderator:
|
||||
raise HTTPException(403, "Not your comment")
|
||||
db.delete(comment)
|
||||
db.commit()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI."""
|
||||
"""AI authoring endpoints on disposable SQLite; no network, no model calls."""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -7,10 +7,9 @@ from unittest.mock import Mock, patch
|
|||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.article import Article
|
||||
from app.models.comment import Comment
|
||||
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
|
||||
from app.models.question import Question
|
||||
from app.routers import articles, comments
|
||||
from app.routers import articles
|
||||
from app.tasks.quiz_tasks import generate_article_draft, generate_article_cards
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
|
@ -23,13 +22,12 @@ DRAFT_RESPONSE = {
|
|||
}
|
||||
|
||||
|
||||
class CommentsAiTests(unittest.TestCase):
|
||||
class ArticleAiTests(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(comments.router, prefix='/comments')
|
||||
self.redis = Mock()
|
||||
self.redis.from_url.return_value = self.redis
|
||||
self.redis.get.return_value = None
|
||||
|
|
@ -49,49 +47,6 @@ class CommentsAiTests(unittest.TestCase):
|
|||
def publish(self, article_id):
|
||||
return self.client.post(f'/articles/{article_id}/publish', json={'published': True})
|
||||
|
||||
def test_comment_visibility_moderation_and_bounds(self):
|
||||
self.bank.user = self.bank.mod
|
||||
article = self.client.post('/articles/', json={
|
||||
"title": "Discussed", "slug": "discussed", "content": "Intro", "sections": []}).json()
|
||||
self.publish(article['id'])
|
||||
self.bank.user = self.bank.owner
|
||||
created = self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Owner pending note'})
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
self.assertEqual(created.json()['status'], 'pending')
|
||||
self.assertEqual(created.json()['own'], True)
|
||||
for payload in ({'article_id': article['id'], 'question_id': 1, 'content': 'x'},
|
||||
{'article_id': article['id'], 'content': ' '},
|
||||
{'article_id': article['id'], 'content': 'x' * 2001},
|
||||
{'article_id': 999, 'content': 'x'},
|
||||
{'question_id': 999, 'content': 'x'}):
|
||||
self.assertIn(self.client.post('/comments/', json=payload).status_code, (400, 404, 422), payload)
|
||||
self.assertEqual(self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Rate limited'}).status_code, 429)
|
||||
listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
|
||||
self.assertEqual(listing['total'], 1) # Own pending visible.
|
||||
self.bank.user = self.bank.peer
|
||||
listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
|
||||
self.assertEqual(listing['total'], 0) # Others' pending hidden.
|
||||
self.assertEqual(self.client.get('/comments/moderation').status_code, 403)
|
||||
self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'}).status_code, 403)
|
||||
self.bank.user = self.bank.mod
|
||||
pending = self.client.get('/comments/moderation').json()
|
||||
self.assertEqual(pending['total'], 1)
|
||||
approved = self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'})
|
||||
self.assertEqual(approved.status_code, 200)
|
||||
self.assertEqual(approved.json()['author_name'], 'Owner')
|
||||
self.bank.user = self.bank.peer
|
||||
listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
|
||||
self.assertEqual(listing['total'], 1)
|
||||
self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'rejected'}).status_code, 403)
|
||||
# Question comments follow bank visibility.
|
||||
self.bank.user = self.bank.peer
|
||||
self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Private leak'}).status_code, 404)
|
||||
self.redis.incr.side_effect = [1]
|
||||
self.bank.user = self.bank.owner
|
||||
self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Own question'}).status_code, 200)
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertEqual(self.client.delete(f"/comments/{created.json()['id']}").status_code, 204)
|
||||
|
||||
def test_ai_endpoints_queue_and_poll(self):
|
||||
self.bank.user = self.bank.mod
|
||||
response = self.client.post('/articles/ai-draft', json={'topic': 'Neonatal jaundice', 'instructions': 'Two sections'})
|
||||
38
docs/TODO.md
38
docs/TODO.md
|
|
@ -301,6 +301,44 @@ Captured so nothing is lost while the article writing runs.
|
|||
toggle, "Continue your study", and a study-analysis donut. The current
|
||||
dashboard becomes this; a separate signed-out landing page comes later.
|
||||
|
||||
## Collections, as shown 2026-09-11 (evening)
|
||||
|
||||
- [ ] **A collections page.** Favorites and the question libraries in one
|
||||
place, as AMBOSS has it: a Table view / Card view pair, a sort control
|
||||
("Last used", descending by default), a count line — "Showing: 1
|
||||
collection" — and a search by title or date. Each collection is a card
|
||||
with its name, its item count, whether it is private, and a ⋯ menu.
|
||||
`/collections/` already backs the folders; this is the page that is
|
||||
missing.
|
||||
|
||||
## Search and AI Mode, as shown 2026-09-11 (evening)
|
||||
|
||||
Two screenshots, one flow.
|
||||
|
||||
- [ ] **The search overlay.** A panel that opens over whatever you are on, with
|
||||
a *Search* / *AI Mode* pair of tabs at the top — the same box asks the
|
||||
corpus or asks the model, and which one is a toggle rather than two
|
||||
separate destinations. Below the field: SEARCH HISTORY, the previous
|
||||
queries, and the keys spelled out — `Ctrl+K` open, `↑↓` navigate
|
||||
suggestions, `Space` use a suggestion, `Enter` submit. Opens from
|
||||
anywhere with Ctrl+K.
|
||||
- [ ] **The overlay in AI Mode.** Switching the toggle to *AI Mode* changes
|
||||
what the same field does: the history and the key hints go, a mode
|
||||
picker appears beside the tabs ("Learning ⌄" — what the assistant is
|
||||
being asked to be), a clear button appears in the field once there is
|
||||
text, and the submit arrow fills in. The panel's own title tracks the
|
||||
chosen mode.
|
||||
- [ ] **Submitting from AI Mode lands in the conversation.** The overlay is
|
||||
only where the question is typed: pressing submit goes to the AI Mode
|
||||
chat with that question already asked and being answered — not to a
|
||||
results list, and not back to an empty box.
|
||||
- [ ] **AI Mode as a page.** A conversation rail on the left grouped by age
|
||||
("Previous 7 days") with a collapse and a compose control; the empty
|
||||
state centres one large ask box — "How can PedsHub help you today?" —
|
||||
with attach and dictate in the box, a tip line under it, and three or
|
||||
four starting prompts as pills with a SHOW MORE beneath. The existing
|
||||
`AiModePage` becomes this.
|
||||
|
||||
## Taxonomy
|
||||
|
||||
- [x] **Systems subsystems** — done 2026-09-10. 69 subsystems created, 305
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
|
|||
const QuizPage = lazyPage(() => import('./pages/QuizPage'))
|
||||
const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage'))
|
||||
const ResultsPage = lazyPage(() => import('./pages/ResultsPage'))
|
||||
const AdminPage = lazyPage(() => import('./pages/AdminPage'))
|
||||
const AccountPage = lazyPage(() => import('./pages/AccountPage'))
|
||||
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'))
|
||||
const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage'))
|
||||
|
|
@ -154,7 +153,10 @@ function AppRoutes() {
|
|||
<Route path="/courses" element={<CoursesPage />} />
|
||||
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
{/* One front door. The dashboard's contents are sections of
|
||||
Settings now; the old address still works for anyone who
|
||||
bookmarked it. */}
|
||||
<Route path="/admin" element={<Navigate to="/settings?s=people" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,23 @@ import { useDialog } from '../hooks/useDialog'
|
|||
|
||||
const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard', 'article']
|
||||
|
||||
export default function AdminPage() {
|
||||
/**
|
||||
* Administration.
|
||||
*
|
||||
* Rendered inside Settings rather than as a page of its own: there should be
|
||||
* one place where the site is configured, not a settings page that links to a
|
||||
* second settings page. `section` names which part to show and comes from the
|
||||
* Settings nav. /admin now redirects into Settings, so the tab bar below is
|
||||
* only reached by rendering this without `embedded` — kept because the tabs
|
||||
* are what the sections are named by, and losing them would lose that.
|
||||
*/
|
||||
export default function AdminPage({ section, embedded = false }) {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { dialogProps, openConfirm } = useDialog()
|
||||
const [tab, setTab] = useState('models')
|
||||
const [ownTab, setOwnTab] = useState('models')
|
||||
const tab = section || ownTab
|
||||
const setTab = setOwnTab
|
||||
const [users, setUsers] = useState([])
|
||||
const [models, setModels] = useState([])
|
||||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' })
|
||||
|
|
@ -330,16 +342,20 @@ export default function AdminPage() {
|
|||
return (
|
||||
<div>
|
||||
<Dialog {...dialogProps} />
|
||||
<div className="card">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
{adminTabs.map(({ id, label }) => (
|
||||
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{/* Inside Settings the section is already named by the nav, and a second
|
||||
row of tabs would be a second way to be somewhere. */}
|
||||
{!embedded && (
|
||||
<div className="card">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
{adminTabs.map(({ id, label }) => (
|
||||
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{success && <div className="alert alert-success">{success}</div>}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,12 @@
|
|||
.set-nav button:hover { background: var(--bg); color: var(--text); }
|
||||
.set-nav button.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
|
||||
.set-nav-icon { width: 20px; text-align: center; font-size: 1rem; }
|
||||
.set-nav-rule { margin: 8px 4px; border: 0; border-top: 1px solid var(--border); }
|
||||
.set-nav-group {
|
||||
margin: 14px 4px 4px; padding: 0 8px;
|
||||
font-size: 0.64rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.set-nav-group:first-child { margin-top: 0; }
|
||||
|
||||
/* ── Panel ────────────────────────────────────────────────────────── */
|
||||
.set-panel {
|
||||
|
|
@ -108,6 +113,28 @@
|
|||
.set-nav::-webkit-scrollbar { display: none; }
|
||||
.set-nav button { white-space: nowrap; border-radius: 999px; border: 1px solid var(--border); }
|
||||
.set-nav button.is-active { border-color: var(--primary); }
|
||||
.set-nav-rule { display: none; }
|
||||
/* The strip scrolls sideways; a heading in the middle of it would read as
|
||||
an item you cannot press. */
|
||||
.set-nav-group { display: none; }
|
||||
.set-nav-icon { display: none; }
|
||||
}
|
||||
|
||||
/* ── Administration rendered in place ─────────────────────────────────
|
||||
The admin sections were written as their own page and bring their own
|
||||
cards. Inside a settings panel that would be a box in a box, so the
|
||||
outermost layer of chrome is taken off and the padding comes from here. */
|
||||
.set-admin > div > .card,
|
||||
.set-admin > div > div > .card {
|
||||
border: 0; border-radius: 0; padding: 0; margin: 0 0 18px; background: none;
|
||||
}
|
||||
.set-admin > div > .card:last-child,
|
||||
.set-admin > div > div > .card:last-child { margin-bottom: 0; }
|
||||
.set-admin h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em;
|
||||
text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.set-admin h3 { font-size: 0.95rem; }
|
||||
.set-admin table { width: 100%; }
|
||||
/* Wide tables scroll inside themselves rather than widening the page. */
|
||||
.set-admin .table-wrap, .set-admin .admin-table-wrap { overflow-x: auto; }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { Fragment, useState, useEffect } from 'react'
|
||||
import { Fragment, Suspense, useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
import api from '../api/client'
|
||||
import ExamSwitcher from '../components/ExamSwitcher'
|
||||
import SitePolicy from '../components/SitePolicy'
|
||||
import lazyPage from '../utils/lazyPage'
|
||||
|
||||
// Only an administrator ever renders this, and it is the largest thing on the
|
||||
// page — so it is not in everyone else's download.
|
||||
const AdminPage = lazyPage(() => import('./AdminPage'))
|
||||
import './SettingsPage.css'
|
||||
|
||||
function Section({ title, description, children }) {
|
||||
|
|
@ -213,12 +218,16 @@ function NextcloudSection() {
|
|||
)
|
||||
}
|
||||
|
||||
function AdminSection() {
|
||||
/**
|
||||
* The places that are their own pages for good reason — a taxonomy tree, an
|
||||
* editorial queue, a job log. Everything that is a *setting* now lives in this
|
||||
* page's own sections rather than behind a link to a second dashboard.
|
||||
*/
|
||||
function ToolsSection() {
|
||||
return (
|
||||
<Section title="Administration" description="Tools for moderators and administrators.">
|
||||
<Section title="Tools" description="Editing surfaces that have a page of their own.">
|
||||
<div className="set-cards">
|
||||
{[
|
||||
{ to: '/admin', icon: '⚙️', label: 'Admin dashboard', desc: 'Models, users and settings' },
|
||||
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
|
||||
{ to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' },
|
||||
{ to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' },
|
||||
|
|
@ -237,6 +246,19 @@ function AdminSection() {
|
|||
)
|
||||
}
|
||||
|
||||
/** One of the admin dashboard's sections, shown in place rather than linked to. */
|
||||
function AdminSection({ title, description, section }) {
|
||||
return (
|
||||
<Section title={title} description={description}>
|
||||
<div className="set-admin">
|
||||
<Suspense fallback={<div className="loading"><div className="spinner" /></div>}>
|
||||
<AdminPage embedded section={section} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
const DOCS_PREVIEW = 5
|
||||
|
||||
function DocumentsSection() {
|
||||
|
|
@ -369,14 +391,6 @@ function DataSection() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings as a set of places, each with its own address.
|
||||
*
|
||||
* It was one 600px column holding the account form, the theme picker, a
|
||||
* Nextcloud integration, a document list and an admin link grid, in that
|
||||
* order, with no way to link to any of it. The section now lives in the URL,
|
||||
* so "change your password" is a link and Back works.
|
||||
*/
|
||||
/** Who may register, and whether sessions can be shared. Administrators only. */
|
||||
function SitePolicySection() {
|
||||
return (
|
||||
|
|
@ -387,43 +401,77 @@ function SitePolicySection() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings as a set of places, each with its own address.
|
||||
*
|
||||
* It was one 600px column holding the account form, the theme picker, a
|
||||
* Nextcloud integration, a document list and a grid of links — one of which
|
||||
* went to a second dashboard with a second row of tabs and a second visual
|
||||
* language. There is one place to configure the site now: the admin sections
|
||||
* are rendered here, under headings that say who they are for, and the section
|
||||
* lives in the URL so "change your password" is a link and Back works.
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const isModerator = isAdmin || user?.role === 'moderator'
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
// group: the heading this sits under. Sections are listed in the order a
|
||||
// person needs them — themselves first, the site last.
|
||||
const sections = [
|
||||
{ key: 'account', icon: '👤', label: 'Account', render: () => <ProfileSection user={user} /> },
|
||||
{ key: 'study', icon: '🎯', label: 'Studying for', render: () => <StudySection /> },
|
||||
{ key: 'appearance', icon: '🎨', label: 'Appearance', render: () => <AppearanceSection /> },
|
||||
{ key: 'data', icon: '🗄️', label: 'Your data', render: () => <DataSection /> },
|
||||
{ key: 'account', group: 'You', icon: '👤', label: 'Account',
|
||||
render: () => <ProfileSection user={user} /> },
|
||||
{ key: 'study', group: 'You', icon: '🎯', label: 'Studying for',
|
||||
render: () => <StudySection /> },
|
||||
{ key: 'appearance', group: 'You', icon: '🎨', label: 'Appearance',
|
||||
render: () => <AppearanceSection /> },
|
||||
{ key: 'data', group: 'You', icon: '🗄️', label: 'Your data',
|
||||
render: () => <DataSection /> },
|
||||
...(isModerator ? [
|
||||
{ key: 'library', icon: '📚', label: 'Documents', divider: true,
|
||||
{ key: 'library', group: 'Content', icon: '📚', label: 'Documents',
|
||||
render: () => <><DocumentsSection /><NextcloudSection /></> },
|
||||
{ key: 'admin', icon: '🛠️', label: 'Administration', render: () => <AdminSection /> },
|
||||
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
|
||||
render: () => <ToolsSection /> },
|
||||
] : []),
|
||||
...(isAdmin ? [
|
||||
{ key: 'policy', icon: '🔒', label: 'Site policy', render: () => <SitePolicySection /> },
|
||||
{ key: 'policy', group: 'The site', icon: '🔒', label: 'Access and joining',
|
||||
render: () => <SitePolicySection /> },
|
||||
{ key: 'people', group: 'The site', icon: '👥', label: 'People',
|
||||
render: () => <AdminSection section="users" title="People"
|
||||
description="Everyone with an account, and what each of them may do." /> },
|
||||
{ key: 'models', group: 'The site', icon: '🧠', label: 'AI models',
|
||||
render: () => <AdminSection section="models" title="AI models"
|
||||
description="Which model answers which kind of request, and what happens when one is unavailable." /> },
|
||||
{ key: 'safety', group: 'The site', icon: '🛡️', label: 'Safety',
|
||||
render: () => <AdminSection section="safety" title="Safety"
|
||||
description="Limits on what the assistant will do, and what it refuses." /> },
|
||||
{ key: 'search', group: 'The site', icon: '🔎', label: 'Search',
|
||||
render: () => <AdminSection section="settings" title="Search and sign-up"
|
||||
description="The embedding model behind semantic search, and how accounts are created." /> },
|
||||
] : []),
|
||||
]
|
||||
|
||||
const requested = params.get('s')
|
||||
const active = sections.find(section => section.key === requested) || sections[0]
|
||||
// Only worth heading the groups when there is more than one of them.
|
||||
const grouped = new Set(sections.map(section => section.group)).size > 1
|
||||
|
||||
return (
|
||||
<div className="set-layout">
|
||||
<div className="set-head">
|
||||
<h1>Settings</h1>
|
||||
<p>Your account, how the site looks, and what it shows you.</p>
|
||||
<p>Your account, how the site looks, and — if it is yours to set — how the site behaves.</p>
|
||||
</div>
|
||||
|
||||
<nav className="set-nav" aria-label="Settings sections">
|
||||
{/* Buttons are direct children so the list can become a scrolling
|
||||
strip on a narrow screen without a wrapper in the way. */}
|
||||
{sections.map(section => (
|
||||
{sections.map((section, i) => (
|
||||
<Fragment key={section.key}>
|
||||
{section.divider && <hr className="set-nav-rule" />}
|
||||
{grouped && section.group !== sections[i - 1]?.group && (
|
||||
<p className="set-nav-group">{section.group}</p>
|
||||
)}
|
||||
<button type="button"
|
||||
className={section.key === active.key ? 'is-active' : undefined}
|
||||
aria-current={section.key === active.key ? 'page' : undefined}
|
||||
|
|
|
|||
|
|
@ -52,15 +52,31 @@ describe('settings', () => {
|
|||
it('keeps moderator sections away from a learner', async () => {
|
||||
mount()
|
||||
await screen.findByRole('heading', { name: 'Account' })
|
||||
expect(screen.queryByRole('button', { name: /Administration/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Tools/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Documents/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers them to a moderator', async () => {
|
||||
it('offers them to a moderator, but not the ones that are the site itself', async () => {
|
||||
currentUser = { ...currentUser, role: 'moderator' }
|
||||
mount()
|
||||
await screen.findByRole('heading', { name: 'Account' })
|
||||
expect(screen.getByRole('button', { name: /Administration/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Tools/ })).toBeInTheDocument()
|
||||
// Who may join and which model answers are an administrator's, not a
|
||||
// moderator's.
|
||||
expect(screen.queryByRole('button', { name: /AI models/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Access and joining/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('holds administration itself rather than linking to a second dashboard', async () => {
|
||||
currentUser = { ...currentUser, role: 'admin' }
|
||||
mount()
|
||||
await screen.findByRole('heading', { name: 'Account' })
|
||||
// The old page offered a grid of links, one of which went to /admin and
|
||||
// its own row of tabs. These are sections of this page now.
|
||||
for (const label of ['People', 'AI models', 'Safety', 'Search']) {
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByRole('link', { name: /Admin dashboard/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the exam objective, which was only reachable from the navbar', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue