diff --git a/backend/app/main.py b/backend/app/main.py index 2257bc6..0374d93 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, exams -from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media +from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -642,6 +642,7 @@ app.include_router(courses.router, prefix="/api/courses", tags=["courses"]) app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"]) app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"]) +app.include_router(search.router, prefix="/api/search", tags=["search"]) @app.get("/api/health") diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py new file mode 100644 index 0000000..e6c6b60 --- /dev/null +++ b/backend/app/routers/search.py @@ -0,0 +1,211 @@ +"""One query across everything a learner can see. + +Each corpus already has its own retrieval and its own rules about who may see +what. This endpoint runs them together and returns one answer, rather than +making somebody guess which of five pages holds the thing they are looking for. + +Two decisions worth stating: + +* Visibility is never re-implemented here. Questions go through the same bank + predicate and exam scope as the question bank, articles through the same draft + rule, cards through deck ownership, images through library grants. A search + page that had its own idea of who may see what is how private content leaks. +* A section match is reported under its article, not beside it. Ten sections of + one article are one result with ten places to start reading, not ten results + that bury everything else. +""" +import logging +import re + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import text as sa_text +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.article import Article, ArticleSectionIndex +from app.models.flashcard import Flashcard, FlashcardDeck +from app.models.media import MediaAsset +from app.models.question import Question +from app.models.user import User +from app.routers.media import readable_libraries +from app.services.quiz_builder import bank_query, exam_scope_predicate +from app.services.search_service import hybrid_ids +from app.utils.auth import get_current_user + +router = APIRouter() +log = logging.getLogger(__name__) + +KINDS = ("article", "question", "flashcard", "media") +# Per-corpus retrieval pool. Wider than what is shown, because visibility +# filtering happens after ranking and can empty a page that had hits. +POOL = 60 + + +def _strip_markup(value: str | None) -> str: + text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", value or "") + text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M) + text = re.sub(r"[*_`>|]", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _snippet(value: str | None, query: str, width: int = 180) -> str: + """A window of the text around the first query word that appears in it. + + Showing the opening of every document makes results look identical; showing + where the match is tells you whether it is the one you meant. + """ + text = _strip_markup(value) + if not text: + return "" + for word in sorted((w for w in re.findall(r"\w{4,}", query.lower())), key=len, reverse=True): + found = text.lower().find(word) + if found >= 0: + start = max(0, found - width // 3) + piece = text[start:start + width] + return ("…" if start else "") + piece.strip() + ("…" if start + width < len(text) else "") + return text[:width] + ("…" if len(text) > width else "") + + +def _ordered(rows, ranked: list[int]): + """Rows back in the order retrieval put them, not the order the DB returned.""" + position = {row_id: index for index, row_id in enumerate(ranked)} + return sorted(rows, key=lambda row: position.get(row.id, len(position))) + + +def _articles(db, user, q, limit): + ranked, _ = hybrid_ids(db, q, "article", limit=POOL) + # A section match belongs to its article, so both rankers feed one result. + section_ranked, _ = hybrid_ids(db, q, "article_section", limit=POOL) + sections = db.query(ArticleSectionIndex).filter( + ArticleSectionIndex.id.in_(section_ranked)).all() if section_ranked else [] + by_article: dict[int, list] = {} + for section in sections: + by_article.setdefault(section.article_id, []).append(section) + + wanted = list(dict.fromkeys([*ranked, *by_article.keys()])) + if not wanted: + return [] + rows = db.query(Article).filter(Article.id.in_(wanted)).all() + if not user.is_moderator: + rows = [a for a in rows if a.status == "published" or a.user_id == user.id] + results = [] + for article in _ordered(rows, wanted)[:limit]: + results.append({ + "id": article.id, + "slug": article.slug, + "title": article.title, + "snippet": _snippet(article.summary or article.content, q), + "status": article.status, + "section_count": len(article.sections or []), + "sections": [ + {"section_id": s.section_id, "title": s.title, "snippet": _snippet(s.content, q)} + for s in by_article.get(article.id, [])[:4] + ], + }) + return results + + +def _questions(db, user, q, limit): + ranked, semantic = hybrid_ids(db, q, "question", limit=POOL) + if not ranked: + return [] + query = bank_query(db, user).filter(Question.id.in_(ranked)) + scope = exam_scope_predicate(db, user) + if scope is not None: + query = query.filter(scope) + rows = _ordered(query.all(), ranked)[:limit] + return [{ + "id": row.id, + "snippet": _snippet(row.question_text, q), + "difficulty": row.difficulty, + # Worth saying: a hit nobody's words predicted came from the meaning. + "match": "semantic" if row.id in semantic else "keyword", + } for row in rows] + + +def _flashcards(db, user, q, limit): + ranked, _ = hybrid_ids(db, q, "flashcard", limit=POOL) + if not ranked: + return [] + own = [d.id for d in db.query(FlashcardDeck.id).filter( + FlashcardDeck.user_id == user.id, FlashcardDeck.deleted_at.is_(None)).all()] + if not own: + return [] + rows = db.query(Flashcard).filter( + Flashcard.id.in_(ranked), Flashcard.deck_id.in_(own)).all() + return [{"id": row.id, "deck_id": row.deck_id, "front": row.front, + "snippet": _snippet(row.back, q)} for row in _ordered(rows, ranked)[:limit]] + + +def _media(db, user, q, limit): + ranked, _ = hybrid_ids(db, q, "media", limit=POOL) + if not ranked: + return [] + query = db.query(MediaAsset).filter(MediaAsset.id.in_(ranked)) + scope = readable_libraries(db, user) + if scope is not None: + query = query.filter(MediaAsset.library_id.in_(scope or {0})) + rows = _ordered(query.all(), ranked)[:limit] + return [{"id": row.id, "path": row.path, "title": row.title, + "snippet": _snippet(row.caption or row.alt_text, q)} for row in rows] + + +FINDERS = {"article": _articles, "question": _questions, + "flashcard": _flashcards, "media": _media} + + +@router.get("") +def search_everything( + q: str = Query("", max_length=500), + kinds: str | None = Query(None, description="Comma-separated subset of article,question,flashcard,media"), + limit: int = Query(10, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Search every corpus at once, grouped by what was found.""" + query_text = (q or "").strip() + wanted = [k for k in (kinds.split(",") if kinds else KINDS) if k in FINDERS] + empty = {"query": query_text, "results": {kind: [] for kind in wanted}, "total": 0} + if len(query_text) < 2: + return empty + + results = {} + for kind in wanted: + try: + results[kind] = FINDERS[kind](db, current_user, query_text, limit) + except Exception: + # One corpus failing is a gap in the answer, not the end of it. + log.warning("Search failed for %s", kind, exc_info=True) + results[kind] = [] + return {"query": query_text, "results": results, + "total": sum(len(rows) for rows in results.values())} + + +@router.get("/suggest") +def suggest( + q: str = Query("", max_length=200), + limit: int = Query(6, ge=1, le=12), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Titles for a typeahead — cheap enough to run on every keystroke. + + Deliberately lexical and prefix-based: a typeahead is finishing the word you + are typing, and a semantic neighbour of half a word is noise. + """ + query_text = (q or "").strip() + if len(query_text) < 2: + return {"suggestions": []} + pattern = f"%{query_text.lower()}%" + rows = db.execute(sa_text(""" + SELECT id, slug, title, status, user_id FROM articles + WHERE lower(title) LIKE :pattern + ORDER BY CASE WHEN lower(title) LIKE :prefix THEN 0 ELSE 1 END, length(title), title + LIMIT :limit + """), {"pattern": pattern, "prefix": f"{query_text.lower()}%", "limit": limit * 2}).fetchall() + visible = [r for r in rows + if r.status == "published" or current_user.is_moderator or r.user_id == current_user.id] + return {"suggestions": [{"kind": "article", "id": r.id, "slug": r.slug, "title": r.title} + for r in visible[:limit]]} diff --git a/backend/tests/test_global_search.py b/backend/tests/test_global_search.py new file mode 100644 index 0000000..7aeb63d --- /dev/null +++ b/backend/tests/test_global_search.py @@ -0,0 +1,117 @@ +"""One query across every corpus, on disposable SQLite; no network or AI. + +The point of these tests is the boundary, not the ranking: a search page that +had its own idea of who may see what is how private content leaks. +""" +import unittest + +import test_quiz_builder as fixtures +from app.models.article import Article, ArticleSectionIndex +from app.models.flashcard import Flashcard, FlashcardDeck +from app.models.question import Question +from app.routers import search + + +class GlobalSearchTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(search.router, prefix='/search') + self.db = self.bank.db + + self.db.add_all([ + Article(id=1, slug='febrile-seizures', title='Febrile seizures', + summary='A seizure with fever in a young child', content='Intro', + sections=[{"id": "a" * 32, "slug": "workup", "title": "Initial workup", "content": "Lumbar puncture"}], + status='published', user_id=3), + Article(id=2, slug='draft-topic', title='Febrile draft', summary='Unpublished febrile notes', + sections=[], status='draft', user_id=3), + ]) + self.db.add(ArticleSectionIndex(id=1, article_id=1, section_id='a' * 32, + title='Initial workup', content='Lumbar puncture in a febrile infant')) + self.db.add_all([ + FlashcardDeck(id=1, title='Mine', user_id=1), + FlashcardDeck(id=2, title='Theirs', user_id=2), + ]) + self.db.flush() + self.db.add_all([ + Flashcard(id=1, deck_id=1, front='Febrile seizure duration', back='Under 15 minutes'), + Flashcard(id=2, deck_id=2, front='Febrile seizure age', back='6 months to 5 years'), + ]) + self.db.query(Question).filter(Question.id == 1).update( + {"question_text": "A child with a febrile seizure lasting two minutes"}) + self.db.query(Question).filter(Question.id == 3).update( + {"question_text": "A private febrile seizure question"}) + self.db.commit() + + def tearDown(self): + self.bank.tearDown() + + def find(self, q='febrile', **params): + response = self.client.get('/search', params={'q': q, **params}) + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def test_one_query_answers_from_every_corpus(self): + self.bank.user = self.bank.owner + data = self.find() + self.assertEqual(data['query'], 'febrile') + self.assertIn(1, [row['id'] for row in data['results']['article']]) + self.assertIn(1, [row['id'] for row in data['results']['question']]) + self.assertIn(1, [row['id'] for row in data['results']['flashcard']]) + self.assertEqual(data['total'], sum(len(v) for v in data['results'].values())) + + def test_a_section_hit_is_reported_under_its_article(self): + self.bank.user = self.bank.owner + article = next(row for row in self.find('lumbar')['results']['article'] if row['id'] == 1) + # Ten sections of one article are one result with ten places to start, + # not ten results burying everything else. + self.assertEqual([s['title'] for s in article['sections']], ['Initial workup']) + self.assertEqual(article['sections'][0]['section_id'], 'a' * 32) + + def test_drafts_stay_invisible_to_everyone_but_their_educator(self): + self.bank.user = self.bank.owner + self.assertNotIn(2, [row['id'] for row in self.find()['results']['article']]) + self.bank.user = self.bank.mod + self.assertIn(2, [row['id'] for row in self.find()['results']['article']]) + + def test_private_questions_and_other_peoples_decks_are_not_searchable(self): + self.bank.user = self.bank.peer + data = self.find() + # Question 3 belongs to another user and is not shared. + self.assertNotIn(3, [row['id'] for row in data['results']['question']]) + # Deck 1 belongs to the owner; the peer's search must not reach into it. + self.assertNotIn(1, [row['id'] for row in data['results']['flashcard']]) + + def test_a_query_too_short_to_mean_anything_asks_nothing(self): + self.bank.user = self.bank.owner + data = self.find('f') + self.assertEqual(data['total'], 0) + self.assertEqual(data['results']['article'], []) + + def test_kinds_narrows_the_answer(self): + self.bank.user = self.bank.owner + data = self.find(kinds='article') + self.assertEqual(set(data['results']), {'article'}) + + def test_a_snippet_shows_where_the_match_is_without_markup(self): + self.db.query(Article).filter(Article.id == 1).update( + {"summary": None, "content": "## Heading\n\nSee ![scan](/uploads/x.png) the **febrile** child."}) + self.db.commit() + self.bank.user = self.bank.owner + snippet = next(row for row in self.find()['results']['article'] if row['id'] == 1)['snippet'] + self.assertIn('febrile', snippet) + self.assertNotIn('##', snippet) + self.assertNotIn('/uploads/', snippet) + + def test_suggestions_prefer_a_prefix_and_respect_drafts(self): + self.bank.user = self.bank.owner + titles = [s['title'] for s in self.client.get( + '/search/suggest', params={'q': 'febrile'}).json()['suggestions']] + self.assertEqual(titles, ['Febrile seizures']) + self.bank.user = self.bank.mod + titles = [s['title'] for s in self.client.get( + '/search/suggest', params={'q': 'febrile'}).json()['suggestions']] + self.assertIn('Febrile draft', titles) + self.assertEqual(self.client.get('/search/suggest', params={'q': 'f'}).json()['suggestions'], []) diff --git a/docs/TODO.md b/docs/TODO.md index b7753d6..9c1dfce 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -12,18 +12,22 @@ Updated 2026-09-10. - [ ] **AI Mode (RAG chat)** — see "AI Mode design" below. Needs: conversation + message tables, the retrieval step, the ID-citation contract, and the chat UI with a thread rail. -- [ ] **Global search page** — one query across questions, articles, sections, - cards and media. Results grouped by article with the matching *sections* - listed beneath (section index already exists). Typeahead with "Go to" and - "Search for". Search / AI Mode toggle. +- [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus + at once and `/search` groups the answer by kind, with section hits listed + under the article they belong to and linked to that section. A header box + offers "go to this article" or "search everything". Each corpus keeps its + own visibility rules — bank predicate and exam scope for questions, the + draft rule for articles, deck ownership for cards, library grants for + images. The Search / AI Mode toggle waits on AI Mode. ## UI fixes raised 2026-09-10 - [x] **Quiz/test categories removed** — done. - [x] **Sessions list shows only a few** — done, with a link to full history. - [x] **Analysis session rail full-height** — done. -- [ ] **Articles page layout** — poor; AMBOSS stacks two menus for this. Check - the live site before rebuilding. +- [x] **Articles page layout** — done 2026-09-10. The page is the column + browser itself: topics and the articles filed under them share a column, + separated by icon, one column per level opened. - [x] **Systems facet duplicates** — done 2026-09-10. 491 redundant "(Parent)" suffixes stripped and 3 sibling pairs merged. Nesting still to do. - [x] **"⚙ Filters2948 questions"** — done. The stylesheet was never imported. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cd2afdf..bfe0424 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,7 @@ 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 SearchPage = lazy(() => import('./pages/SearchPage')) const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage')) const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage')) @@ -99,6 +100,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> {/* Cross-references in article prose address a topic by slug, which diff --git a/frontend/src/components/GlobalSearch.css b/frontend/src/components/GlobalSearch.css new file mode 100644 index 0000000..0df4c39 --- /dev/null +++ b/frontend/src/components/GlobalSearch.css @@ -0,0 +1,38 @@ +/* Header search box and its typeahead. */ + +.gs { position: relative; flex: 1; max-width: 420px; min-width: 0; } + +.gs-input { + width: 100%; min-height: 38px; padding: 8px 14px; + border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 8px; + background: rgba(255, 255, 255, 0.12); color: var(--navbar-fg); + font: inherit; font-size: 0.86rem; +} +.gs-input::placeholder { color: var(--navbar-fg); opacity: 0.65; } +.gs-input:focus { outline: none; background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.5); } + +.gs-menu { + position: absolute; z-index: 60; top: calc(100% + 6px); left: 0; right: 0; + list-style: none; margin: 0; padding: 4px; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.18); + max-height: 60vh; overflow-y: auto; +} +.gs-option { + display: flex; align-items: baseline; gap: 8px; width: 100%; + min-height: 40px; padding: 8px 10px; border: 0; border-radius: 7px; + background: none; font: inherit; font-size: 0.86rem; color: var(--text); + text-align: left; cursor: pointer; +} +.gs-option.is-active, .gs-option:hover { background: var(--bg); } +.gs-option-kind { + flex-shrink: 0; font-size: 0.63rem; font-weight: 700; letter-spacing: 0.05em; + text-transform: uppercase; color: var(--text-subtle); +} +.gs-option-title { flex: 1; min-width: 0; overflow-wrap: anywhere; } +/* Always last, always available: not every search is for an article title. */ +.gs-all { border-top: 1px solid var(--border); border-radius: 0 0 7px 7px; color: var(--primary); font-weight: 600; } + +@media (max-width: 900px) { + .gs { display: none; } +} diff --git a/frontend/src/components/GlobalSearch.jsx b/frontend/src/components/GlobalSearch.jsx new file mode 100644 index 0000000..1e30d51 --- /dev/null +++ b/frontend/src/components/GlobalSearch.jsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import api from '../api/client' +import './GlobalSearch.css' + +/** + * The search box in the header. + * + * Two ways out, deliberately: pick a suggestion to go straight to that article, + * or press Enter to search everything. A typeahead that only offered titles + * would strand anybody looking for a question or a card. + * + * Suggestions are lexical and prefix-first, because a typeahead is finishing the + * word you are typing; a semantic neighbour of half a word is noise. + */ +export default function GlobalSearch() { + const [query, setQuery] = useState('') + const [items, setItems] = useState([]) + const [open, setOpen] = useState(false) + const [active, setActive] = useState(-1) + const box = useRef(null) + const navigate = useNavigate() + + useEffect(() => { + if (query.trim().length < 2) { setItems([]); return } + const timer = setTimeout(() => { + api.get('/search/suggest', { params: { q: query.trim() } }) + .then(res => { setItems(res.data?.suggestions || []); setActive(-1) }) + .catch(() => setItems([])) + }, 180) // A pause, not a keystroke: typing must not be a request each. + return () => clearTimeout(timer) + }, [query]) + + useEffect(() => { + const away = (event) => { if (!box.current?.contains(event.target)) setOpen(false) } + document.addEventListener('mousedown', away) + return () => document.removeEventListener('mousedown', away) + }, []) + + const go = (path) => { setOpen(false); setQuery(''); navigate(path) } + + const onKeyDown = (event) => { + if (event.key === 'ArrowDown') { event.preventDefault(); setActive(i => Math.min(i + 1, items.length - 1)); setOpen(true) } + else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(i => Math.max(i - 1, -1)) } + else if (event.key === 'Escape') { setOpen(false) } + else if (event.key === 'Enter') { + event.preventDefault() + if (active >= 0 && items[active]) go(`/articles/${items[active].id}`) + else if (query.trim().length >= 2) go(`/search?q=${encodeURIComponent(query.trim())}`) + } + } + + return ( +
+ { setQuery(e.target.value); setOpen(true) }} + onFocus={() => setOpen(true)} onKeyDown={onKeyDown} /> + {open && query.trim().length >= 2 && ( +
    + {items.map((item, index) => ( +
  • + +
  • + ))} +
  • + +
  • +
+ )} +
+ ) +} diff --git a/frontend/src/components/GlobalSearch.test.jsx b/frontend/src/components/GlobalSearch.test.jsx new file mode 100644 index 0000000..f4a00fc --- /dev/null +++ b/frontend/src/components/GlobalSearch.test.jsx @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import GlobalSearch from './GlobalSearch' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn() } })) + +const Where = () => { const l = useLocation(); return {l.pathname}{l.search} } + +const mount = () => render( + + + } /> + ) + +describe('header search', () => { + beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ data: { suggestions: [ + { kind: 'article', id: 4, slug: 'asthma', title: 'Asthma in children' }, + ] } }) + }) + + it('waits for a pause before asking, and asks nothing for one letter', async () => { + mount() + await userEvent.type(screen.getByRole('searchbox'), 'a') + await new Promise(r => setTimeout(r, 250)) + expect(api.get).not.toHaveBeenCalled() + + await userEvent.type(screen.getByRole('searchbox'), 'sthma') + await waitFor(() => expect(api.get).toHaveBeenCalledWith('/search/suggest', { params: { q: 'asthma' } })) + // One request for the whole word, not one per keystroke. + expect(api.get).toHaveBeenCalledTimes(1) + }) + + it('goes straight to a suggestion, or searches everything', async () => { + mount() + await userEvent.type(screen.getByRole('searchbox'), 'asthma') + await screen.findByRole('option', { name: /Asthma in children/ }) + await userEvent.click(screen.getByRole('option', { name: /Asthma in children/ })) + expect(screen.getByTestId('where')).toHaveTextContent('/articles/4') + }) + + it('offers searching everything even when a title matches', async () => { + mount() + await userEvent.type(screen.getByRole('searchbox'), 'asthma') + await screen.findByRole('option', { name: /Asthma in children/ }) + await userEvent.click(screen.getByRole('button', { name: /Search everything/ })) + expect(screen.getByTestId('where')).toHaveTextContent('/search?q=asthma') + }) + + it('Enter searches everything unless a suggestion is highlighted', async () => { + mount() + const input = screen.getByRole('searchbox') + await userEvent.type(input, 'asthma') + await screen.findByRole('option', { name: /Asthma in children/ }) + await userEvent.keyboard('{Enter}') + expect(screen.getByTestId('where')).toHaveTextContent('/search?q=asthma') + }) + + it('arrow keys pick a suggestion, and Enter follows it', async () => { + mount() + await userEvent.type(screen.getByRole('searchbox'), 'asthma') + await screen.findByRole('option', { name: /Asthma in children/ }) + await userEvent.keyboard('{ArrowDown}{Enter}') + expect(screen.getByTestId('where')).toHaveTextContent('/articles/4') + }) +}) diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 76a2e48..71c1195 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' import ExamSwitcher from './ExamSwitcher' +import GlobalSearch from './GlobalSearch' function JobsBadge({ jobs }) { const [open, setOpen] = useState(false) @@ -116,6 +117,7 @@ export default function Navbar({ onSignIn, onRegister }) {
setMenuOpen(false)}>🏥 PedsHub {user && window.location.reload()} />} + {user && } {user ? ( <> diff --git a/frontend/src/index.css b/frontend/src/index.css index 2096835..a156b90 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -115,7 +115,7 @@ body { /* ── Navbar ─────────────────────────────────────────────────── */ .navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 0 0 0; margin-bottom: 32px; position: sticky; top: 0; z-index: 50; } -.navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; height: 52px; } +.navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; gap: 14px; height: 52px; } .navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; } [data-theme="markdown"] .navbar .logo { color: #d4a96a; } .nav-desktop { display: flex; gap: 4px; align-items: center; } diff --git a/frontend/src/pages/SearchPage.css b/frontend/src/pages/SearchPage.css new file mode 100644 index 0000000..a4cfa55 --- /dev/null +++ b/frontend/src/pages/SearchPage.css @@ -0,0 +1,51 @@ +/* One query across everything, grouped by what was found. */ + +.search-page { max-width: 820px; margin: 0 auto; padding-bottom: 48px; } + +.search-hero { display: flex; gap: 10px; margin-bottom: 14px; } +.search-input { + flex: 1; min-width: 0; min-height: 48px; padding: 12px 16px; + border: 1px solid var(--border); border-radius: 10px; + background: var(--input-bg); color: var(--text); font-size: 1rem; +} +.search-input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: var(--primary); } + +.search-hint { color: var(--text-muted); font-size: 0.88rem; line-height: 1.6; margin: 18px 0 0; } +.search-error { color: var(--wrong-fg); font-size: 0.86rem; } +.search-count { color: var(--text-muted); font-size: 0.84rem; margin: 0 0 8px; } + +.search-group { margin-top: 22px; } +.search-group h2 { + display: flex; align-items: baseline; gap: 8px; margin: 0 0 6px; + font-size: 0.76rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); +} +.search-group-count { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); } + +.search-result { padding: 12px 0; border-bottom: 1px solid var(--border); } +.search-result-title { font-size: 0.98rem; font-weight: 650; color: var(--primary); text-decoration: none; } +.search-result-title:hover { text-decoration: underline; } +.search-snippet { margin: 4px 0 0; font-size: 0.86rem; line-height: 1.55; color: var(--text-muted); } + +.search-badge, .search-draft { + margin-left: 8px; font-size: 0.64rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; padding: 1px 7px; border-radius: 10px; + background: var(--option-sel-bg); color: var(--primary); +} +.search-draft { background: #fef3c7; color: #92400e; } + +/* Sections sit under the article they belong to, indented, because they are + places to start reading it — not separate results. */ +.search-sections { list-style: none; margin: 8px 0 0; padding-left: 12px; border-left: 2px solid var(--border); } +.search-sections li { padding: 4px 0; font-size: 0.86rem; } +.search-sections a { color: var(--text); text-decoration: none; font-weight: 600; } +.search-sections a:hover { color: var(--primary); } +.search-sections .search-snippet { display: inline; margin: 0; } + +.search-media { list-style: none; margin: 0; padding: 0; } +.search-media li { padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 0.88rem; } + +@media (max-width: 640px) { + .search-hero { flex-direction: column; } + .search-hero .btn { width: 100%; min-height: 44px; } +} diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx new file mode 100644 index 0000000..e4ad10d --- /dev/null +++ b/frontend/src/pages/SearchPage.jsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import api from '../api/client' +import './SearchPage.css' + +const GROUPS = [ + { kind: 'article', label: 'Reading', empty: 'No articles match.' }, + { kind: 'question', label: 'Questions', empty: 'No questions match.' }, + { kind: 'flashcard', label: 'Cards', empty: 'No cards match.' }, + { kind: 'media', label: 'Images', empty: 'No images match.' }, +] + +/** + * One query across everything. + * + * Grouped by what was found rather than interleaved by score: a question and an + * article are different kinds of answer, and a single ranked list makes you read + * every row to work out which kind each one is. + * + * A section match is shown under its article. Ten sections of one article are + * one result with ten places to start, not ten results burying everything else. + */ +export default function SearchPage() { + const [params, setParams] = useSearchParams() + const query = params.get('q') || '' + const [draft, setDraft] = useState(query) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const seq = useRef(0) + + const run = useCallback((text) => { + if (text.trim().length < 2) { setData(null); return } + const mine = ++seq.current + setLoading(true); setError('') + api.get('/search', { params: { q: text.trim(), limit: 8 } }) + .then(res => { if (mine === seq.current) setData(res.data) }) + .catch(() => { if (mine === seq.current) setError('Search is unavailable right now.') }) + .finally(() => { if (mine === seq.current) setLoading(false) }) + }, []) + + useEffect(() => { setDraft(query); run(query) }, [query, run]) + + const submit = (event) => { + event.preventDefault() + setParams(draft.trim() ? { q: draft.trim() } : {}) + } + + const groups = data ? GROUPS.filter(g => data.results[g.kind]) : [] + const found = data?.total || 0 + + return ( +
+
+ setDraft(e.target.value)} + placeholder="Search reading, questions, cards and images" + aria-label="Search everything" /> + +
+ + {!query && ( +

+ Search finds meaning as well as words, so “fever in a returning traveller” + reaches the right reading even when the article never uses that phrase. +

+ )} + + {error &&

{error}

} + {loading &&
} + + {!loading && data && ( + <> +

+ {found === 0 ? `Nothing matches “${data.query}”.` : `${found} result${found === 1 ? '' : 's'} for “${data.query}”`} +

+ + {groups.map(group => { + const rows = data.results[group.kind] + if (!rows.length) return null + return ( +
+

{group.label}{rows.length}

+ + {group.kind === 'article' && rows.map(row => ( +
+ {row.title} + {row.status !== 'published' && Draft} + {row.snippet &&

{row.snippet}

} + {/* Where inside the article the match is — the point of + indexing sections rather than whole documents. */} + {row.sections.length > 0 && ( +
    + {row.sections.map(sec => ( +
  • + {sec.title} + {sec.snippet && — {sec.snippet}} +
  • + ))} +
+ )} +
+ ))} + + {group.kind === 'question' && rows.map(row => ( +
+ + Question #{row.id} + + {row.match === 'semantic' && meaning} +

{row.snippet}

+
+ ))} + + {group.kind === 'flashcard' && rows.map(row => ( +
+ {row.front} + {row.snippet &&

{row.snippet}

} +
+ ))} + + {group.kind === 'media' && ( +
    + {rows.map(row => ( +
  • + {row.title || `Image #${row.id}`} + {row.snippet && — {row.snippet}} +
  • + ))} +
+ )} +
+ ) + })} + + )} +
+ ) +} diff --git a/frontend/src/pages/SearchPage.test.jsx b/frontend/src/pages/SearchPage.test.jsx new file mode 100644 index 0000000..339a7e8 --- /dev/null +++ b/frontend/src/pages/SearchPage.test.jsx @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import SearchPage from './SearchPage' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn() } })) + +const results = { + query: 'febrile seizure', + total: 3, + results: { + article: [{ + id: 1, slug: 'febrile-seizures', title: 'Febrile seizures', status: 'published', + snippet: '…a seizure with fever…', section_count: 4, + sections: [{ section_id: 'a'.repeat(32), title: 'Initial workup', snippet: '…lumbar puncture…' }], + }], + question: [{ id: 12, snippet: 'A 2-year-old with a brief generalised seizure…', difficulty: 'medium', match: 'semantic' }], + flashcard: [{ id: 5, deck_id: 2, front: 'Simple vs complex', snippet: 'Under 15 minutes…' }], + media: [], + }, +} + +const mount = (path = '/search?q=febrile+seizure') => render( + } />) + +describe('global search', () => { + beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ data: results }) }) + + it('groups results by kind rather than interleaving them', async () => { + mount() + expect(await screen.findByRole('heading', { name: /Reading/ })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /Questions/ })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /Cards/ })).toBeInTheDocument() + // A group with nothing in it is not drawn at all. + expect(screen.queryByRole('heading', { name: /Images/ })).not.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('3 results for “febrile seizure”') + }) + + it('lists a matching section under its article, linked to that section', async () => { + mount() + const article = (await screen.findByText('Febrile seizures')).closest('.search-result') + const section = within(article).getByRole('link', { name: 'Initial workup' }) + expect(section).toHaveAttribute('href', `/articles/1?section=${'a'.repeat(32)}`) + expect(within(article).getByText(/lumbar puncture/)).toBeInTheDocument() + }) + + it('says when a hit came from meaning rather than wording', async () => { + mount() + const question = (await screen.findByText(/2-year-old/)).closest('.search-result') + expect(within(question).getByText('meaning')).toBeInTheDocument() + }) + + it('asks for nothing until there is something to ask', async () => { + mount('/search') + expect(api.get).not.toHaveBeenCalled() + await userEvent.type(screen.getByLabelText('Search everything'), 'a') + await userEvent.click(screen.getByRole('button', { name: 'Search' })) + expect(api.get).not.toHaveBeenCalled() + }) + + it('reports an empty result honestly', async () => { + api.get.mockResolvedValue({ data: { query: 'zzz', total: 0, results: { article: [], question: [], flashcard: [], media: [] } } }) + mount('/search?q=zzz') + expect(await screen.findByRole('status')).toHaveTextContent('Nothing matches “zzz”') + }) + + it('surfaces a failure instead of showing an empty page', async () => { + api.get.mockRejectedValue(new Error('down')) + mount() + expect(await screen.findByRole('alert')).toHaveTextContent('unavailable') + }) +})