diff --git a/backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py b/backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py new file mode 100644 index 0000000..ab2341d --- /dev/null +++ b/backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py @@ -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), + ) diff --git a/backend/app/main.py b/backend/app/main.py index b5cd3c2..50a717b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c6dd29c..4c7eae3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py deleted file mode 100644 index fc13b22..0000000 --- a/backend/app/models/comment.py +++ /dev/null @@ -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") diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py deleted file mode 100644 index 49a5776..0000000 --- a/backend/app/routers/comments.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_comments_ai.py b/backend/tests/test_article_ai.py similarity index 67% rename from backend/tests/test_comments_ai.py rename to backend/tests/test_article_ai.py index 422edb8..866d842 100644 --- a/backend/tests/test_comments_ai.py +++ b/backend/tests/test_article_ai.py @@ -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'}) diff --git a/docs/TODO.md b/docs/TODO.md index d664bd3..46c440c 100644 --- a/docs/TODO.md +++ b/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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7a0fdec..29aca0d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> - } /> + {/* One front door. The dashboard's contents are sections of + Settings now; the old address still works for anyone who + bookmarked it. */} + } /> diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index 75b46b0..3013003 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -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 (
-
-

Admin Dashboard

-
- {adminTabs.map(({ id, label }) => ( - - ))} + {/* 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 && ( +
+

Admin Dashboard

+
+ {adminTabs.map(({ id, label }) => ( + + ))} +
-
+ )} {error &&
{error}
} {success &&
{success}
} diff --git a/frontend/src/pages/SettingsPage.css b/frontend/src/pages/SettingsPage.css index 185fd65..e56143d 100644 --- a/frontend/src/pages/SettingsPage.css +++ b/frontend/src/pages/SettingsPage.css @@ -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; } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index a15f9f8..504ad99 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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 ( -
+
{[ - { 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 ( +
+
+
}> + +
+
+
+ ) +} + 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: () => }, - { key: 'study', icon: '🎯', label: 'Studying for', render: () => }, - { key: 'appearance', icon: '🎨', label: 'Appearance', render: () => }, - { key: 'data', icon: '🗄️', label: 'Your data', render: () => }, + { key: 'account', group: 'You', icon: '👤', label: 'Account', + render: () => }, + { key: 'study', group: 'You', icon: '🎯', label: 'Studying for', + render: () => }, + { key: 'appearance', group: 'You', icon: '🎨', label: 'Appearance', + render: () => }, + { key: 'data', group: 'You', icon: '🗄️', label: 'Your data', + render: () => }, ...(isModerator ? [ - { key: 'library', icon: '📚', label: 'Documents', divider: true, + { key: 'library', group: 'Content', icon: '📚', label: 'Documents', render: () => <> }, - { key: 'admin', icon: '🛠️', label: 'Administration', render: () => }, + { key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools', + render: () => }, ] : []), ...(isAdmin ? [ - { key: 'policy', icon: '🔒', label: 'Site policy', render: () => }, + { key: 'policy', group: 'The site', icon: '🔒', label: 'Access and joining', + render: () => }, + { key: 'people', group: 'The site', icon: '👥', label: 'People', + render: () => }, + { key: 'models', group: 'The site', icon: '🧠', label: 'AI models', + render: () => }, + { key: 'safety', group: 'The site', icon: '🛡️', label: 'Safety', + render: () => }, + { key: 'search', group: 'The site', icon: '🔎', label: 'Search', + render: () => }, ] : []), ] 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 (

Settings

-

Your account, how the site looks, and what it shows you.

+

Your account, how the site looks, and — if it is yours to set — how the site behaves.