From bc77ba83aeb3b00db8619d665553d2d18bc54a61 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 03:33:37 +0200 Subject: [PATCH] feat: a collections page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favorites and the question libraries in one place. Card and Table views with the choice remembered, sort by last used / created / name / size with a direction control, a count line, and a search over name and date. Favorites leads as a fixed row: it is the one shelf nobody made and everybody has, so it cannot be renamed or deleted. Sorted by when each was last used, not when it was made — the order things were created in is nobody's mental model of their own shelf. A library nobody has opened falls back to its age, because it is newer to the learner than it is to the database. That needed `user_collections.last_used_at`: null on every existing row, since backfilling from created_at would invent a use that never happened. A shelf opens in place rather than linking away. The obvious link would have been /questions?collection=N, and there is no page there that reads it — the old bank browser was dismantled — so the card would have led nowhere. Questions can be taken back out from the open shelf, and any shelf can be sat as a session through the existing explicit_ids builder. The ⋯ menu moved out of QuizPage into components/MoreMenu; the player keeps its own look and its own children through className props. It no longer closes on any click inside, which the player's feedback form and share dialog were relying on by accident. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .../f2a3b4c5d6e7_collection_last_used.py | 27 ++ backend/app/models/collection.py | 3 + backend/app/routers/collections.py | 46 ++- backend/tests/test_collections.py | 90 +++++ docs/TODO.md | 16 +- frontend/src/App.jsx | 2 + frontend/src/components/MoreMenu.css | 15 + frontend/src/components/MoreMenu.jsx | 48 +++ frontend/src/components/Navbar.jsx | 3 + frontend/src/pages/CollectionsPage.css | 109 ++++++ frontend/src/pages/CollectionsPage.jsx | 327 ++++++++++++++++++ frontend/src/pages/CollectionsPage.test.jsx | 152 ++++++++ frontend/src/pages/QuizPage.jsx | 23 +- 13 files changed, 827 insertions(+), 34 deletions(-) create mode 100644 backend/alembic/versions/f2a3b4c5d6e7_collection_last_used.py create mode 100644 backend/tests/test_collections.py create mode 100644 frontend/src/components/MoreMenu.css create mode 100644 frontend/src/components/MoreMenu.jsx create mode 100644 frontend/src/pages/CollectionsPage.css create mode 100644 frontend/src/pages/CollectionsPage.jsx create mode 100644 frontend/src/pages/CollectionsPage.test.jsx diff --git a/backend/alembic/versions/f2a3b4c5d6e7_collection_last_used.py b/backend/alembic/versions/f2a3b4c5d6e7_collection_last_used.py new file mode 100644 index 0000000..c775fc4 --- /dev/null +++ b/backend/alembic/versions/f2a3b4c5d6e7_collection_last_used.py @@ -0,0 +1,27 @@ +"""Remember when a collection was last opened. + +A list of libraries sorted by when they were made is a list in the order you +happened to create things, which is not the order anyone looks for them in. +"Last used" is, and it is the default sort on the collections page. + +Null on every existing row: we did not record it before, and backfilling from +`created_at` would invent a use that never happened. + +Revision ID: f2a3b4c5d6e7 +Revises: e1f2a3b4c5d6 +""" +import sqlalchemy as sa +from alembic import op + +revision = "f2a3b4c5d6e7" +down_revision = "e1f2a3b4c5d6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("user_collections", sa.Column("last_used_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("user_collections", "last_used_at") diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py index c669ffd..9400a3c 100644 --- a/backend/app/models/collection.py +++ b/backend/app/models/collection.py @@ -11,6 +11,9 @@ class UserCollection(Base): user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) title = Column(String(200), nullable=False) created_at = Column(DateTime, default=datetime.utcnow) + # When it was last opened or added to. Null means never since this was + # recorded, which is not the same claim as "never used". + last_used_at = Column(DateTime, nullable=True) class UserCollectionQuestion(Base): diff --git a/backend/app/routers/collections.py b/backend/app/routers/collections.py index 83689d1..e62ba02 100644 --- a/backend/app/routers/collections.py +++ b/backend/app/routers/collections.py @@ -1,4 +1,6 @@ """Personal question libraries (saved questions).""" +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, field_validator from sqlalchemy.orm import Session @@ -29,6 +31,26 @@ class CollectionQuestionIn(BaseModel): question_id: int +def _touch(db, collection): + """Mark a collection as used now. Opening one counts; so does adding to it.""" + collection.last_used_at = datetime.utcnow() + + +def _as_json(db, collection) -> dict: + return { + "id": collection.id, + "title": collection.title, + "question_count": db.query(UserCollectionQuestion).filter( + UserCollectionQuestion.collection_id == collection.id).count(), + "created_at": collection.created_at, + "last_used_at": collection.last_used_at, + # Every library is one person's. Said plainly rather than assumed, + # because the page shows it and a learner should not have to guess + # whether saving a question published it. + "private": True, + } + + def _own(db, user, collection_id): collection = db.get(UserCollection, collection_id) if not collection: @@ -40,10 +62,15 @@ def _own(db, user, collection_id): @router.get("/") def list_collections(db: Session = Depends(get_db), user: User = Depends(get_current_user)): - rows = db.query(UserCollection).filter(UserCollection.user_id == user.id).order_by( - UserCollection.created_at.desc()).all() - return [{"id": c.id, "title": c.title, "question_count": db.query(UserCollectionQuestion).filter( - UserCollectionQuestion.collection_id == c.id).count()} for c in rows] + """Every library this learner keeps, most recently used first. + + A never-opened library sorts by when it was made, beneath everything that + has been used — it is newer to the learner than it is to the database. + """ + rows = db.query(UserCollection).filter(UserCollection.user_id == user.id).all() + rows.sort(key=lambda c: (c.last_used_at or c.created_at or datetime.min, + c.created_at or datetime.min), reverse=True) + return [_as_json(db, c) for c in rows] @router.post("/", status_code=201) @@ -52,7 +79,7 @@ def create_collection(data: CollectionCreate, db: Session = Depends(get_db), use db.add(collection) db.commit() db.refresh(collection) - return {"id": collection.id, "title": collection.title, "question_count": 0} + return _as_json(db, collection) @router.patch("/{collection_id}") @@ -61,7 +88,7 @@ def rename_collection(collection_id: int, data: CollectionCreate, db: Session = collection = _own(db, user, collection_id) collection.title = data.title db.commit() - return {"id": collection.id, "title": collection.title} + return _as_json(db, collection) @router.delete("/{collection_id}", status_code=204) @@ -75,6 +102,9 @@ def collection_questions(collection_id: int, db: Session = Depends(get_db), user collection = _own(db, user, collection_id) rows = db.query(Question).join(UserCollectionQuestion, UserCollectionQuestion.question_id == Question.id).filter( UserCollectionQuestion.collection_id == collection.id).all() + # Opening a library is using it, which is what the collections page sorts by. + _touch(db, collection) + db.commit() return [{"id": q.id, "question_text": q.question_text} for q in rows] @@ -87,6 +117,7 @@ def add_collection_question(collection_id: int, question_id: int, db: Session = if db.query(UserCollectionQuestion.id).filter_by(collection_id=collection.id, question_id=question_id).first(): return {"added": False} db.add(UserCollectionQuestion(collection_id=collection.id, question_id=question_id)) + _touch(db, collection) db.commit() return {"added": True} @@ -94,7 +125,8 @@ def add_collection_question(collection_id: int, question_id: int, db: Session = @router.delete("/{collection_id}/questions/{question_id}", status_code=204) def remove_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): - _own(db, user, collection_id) + collection = _own(db, user, collection_id) db.query(UserCollectionQuestion).filter_by(collection_id=collection_id, question_id=question_id).delete( synchronize_session=False) + _touch(db, collection) db.commit() diff --git a/backend/tests/test_collections.py b/backend/tests/test_collections.py new file mode 100644 index 0000000..4c1951f --- /dev/null +++ b/backend/tests/test_collections.py @@ -0,0 +1,90 @@ +"""Personal question libraries: what they report, and who may touch them. + +Disposable SQLite, as everywhere in these tests. The rule the page depends on +is the ordering: most recently used first, and a library nobody has opened +sorts by when it was made rather than claiming a use that never happened. +""" +import unittest + +import test_quiz_builder as fixtures +from app.models.collection import UserCollection +from app.routers import collections + + +class CollectionTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + self.client.app.include_router(collections.router, prefix='/collections') + + def tearDown(self): + self.bank.tearDown() + + def make(self, title): + response = self.client.post('/collections/', json={'title': title}) + self.assertEqual(response.status_code, 201, response.text) + return response.json() + + def listed(self): + response = self.client.get('/collections/') + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def test_a_new_library_reports_what_the_page_shows(self): + row = self.make('Cardiology misses') + self.assertEqual(row['title'], 'Cardiology misses') + self.assertEqual(row['question_count'], 0) + self.assertTrue(row['private']) + self.assertIsNone(row['last_used_at']) + self.assertIsNotNone(row['created_at']) + + def test_adding_a_question_counts_it_and_marks_the_library_used(self): + row = self.make('Saved') + self.assertEqual(self.client.put(f"/collections/{row['id']}/questions/1").status_code, 200) + # The same question twice is one question. + self.assertFalse(self.client.put(f"/collections/{row['id']}/questions/1").json()['added']) + after = self.listed()[0] + self.assertEqual(after['question_count'], 1) + self.assertIsNotNone(after['last_used_at']) + + def test_opening_a_library_is_using_it(self): + row = self.make('Saved') + self.assertIsNone(self.listed()[0]['last_used_at']) + self.assertEqual(self.client.get(f"/collections/{row['id']}/questions").status_code, 200) + self.assertIsNotNone(self.listed()[0]['last_used_at']) + + def test_most_recently_used_first_and_the_unused_by_age(self): + from datetime import datetime + + old = self.make('Older') + new = self.make('Newer') + used = self.make('Used long ago') + # An explicit clock: two rows made in the same second cannot be + # ordered by when they were made. + self.db.get(UserCollection, old['id']).created_at = datetime(2026, 1, 1) + self.db.get(UserCollection, new['id']).created_at = datetime(2026, 6, 1) + row = self.db.get(UserCollection, used['id']) + row.created_at = datetime(2025, 1, 1) + row.last_used_at = datetime(2026, 9, 1) + self.db.commit() + # Used beats made, and among the never-used the newer one is first. + self.assertEqual([c['title'] for c in self.listed()], + ['Used long ago', 'Newer', 'Older']) + + def test_a_library_belongs_to_one_person(self): + row = self.make('Mine') + self.bank.user = self.bank.peer + self.assertEqual(self.listed(), []) + self.assertEqual(self.client.patch(f"/collections/{row['id']}", + json={'title': 'Yours'}).status_code, 403) + self.assertEqual(self.client.delete(f"/collections/{row['id']}").status_code, 403) + self.assertEqual(self.client.get(f"/collections/{row['id']}/questions").status_code, 403) + + def test_a_question_nobody_can_see_cannot_be_saved(self): + row = self.make('Mine') + # Question 4 is another learner's and not shared; question 3 is this + # learner's own, unshared, and theirs to save. + self.assertEqual(self.client.put(f"/collections/{row['id']}/questions/4").status_code, 404) + self.assertEqual(self.client.put(f"/collections/{row['id']}/questions/3").status_code, 200) diff --git a/docs/TODO.md b/docs/TODO.md index 4b0c92e..472933c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -383,13 +383,15 @@ and nothing reaches the bank until an administrator has read it. ## 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. +- [x] **A collections page** — done 2026-09-12. `/collections`, in the section + bar beside Qbank. Card and Table views (the choice is remembered), sort + by last used / created / name / size with a direction control, a count + line, and a search over name and date. Favorites leads as a fixed row. + A shelf opens **in place** rather than linking away: there is no + browsable question list to send anyone to, and a link that goes nowhere + is worse than no link. Questions can be taken back out from there, and + any shelf can be sat as a session. `user_collections.last_used_at` is + new — opening or adding to a library counts as using it. ## Search and AI Mode, as shown 2026-09-11 (evening) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f844141..7bbdeba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,7 @@ const SearchPage = lazyPage(() => import('./pages/SearchPage')) const AnalysisSessionPage = lazyPage(() => import('./pages/AnalysisSessionPage')) const AiModePage = lazyPage(() => import('./pages/AiModePage')) const MediaPage = lazyPage(() => import('./pages/MediaPage')) +const CollectionsPage = lazyPage(() => import('./pages/CollectionsPage')) const EditorialPage = lazyPage(() => import('./pages/EditorialPage')) const AccessPage = lazyPage(() => import('./pages/AccessPage')) const HandbookPage = lazyPage(() => import('./pages/HandbookPage')) @@ -174,6 +175,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/MoreMenu.css b/frontend/src/components/MoreMenu.css new file mode 100644 index 0000000..fe05ec3 --- /dev/null +++ b/frontend/src/components/MoreMenu.css @@ -0,0 +1,15 @@ +.mm { position: relative; } +.mm-menu { + position: absolute; right: 0; top: calc(100% + 6px); z-index: 40; + min-width: 210px; padding: 6px; text-align: left; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: 10px; box-shadow: 0 10px 26px rgba(15, 23, 42, 0.16); +} +.mm-item { + display: block; width: 100%; padding: 8px 10px; text-align: left; + font: inherit; font-size: 0.85rem; color: var(--text); + background: none; border: 0; border-radius: 7px; cursor: pointer; + text-decoration: none; +} +.mm-item:hover { background: var(--bg); } +.mm-item.is-danger { color: var(--wrong-fg); } diff --git a/frontend/src/components/MoreMenu.jsx b/frontend/src/components/MoreMenu.jsx new file mode 100644 index 0000000..88a4104 --- /dev/null +++ b/frontend/src/components/MoreMenu.jsx @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react' +import './MoreMenu.css' + +/** + * The ⋯ button and the little menu under it. + * + * Occasional actions fold away behind one control rather than each taking a + * slot in a row that is read every time. Closes on Escape and on a click + * anywhere else, because a menu that stays open is a menu in the way. + * + * `className` and `menuClassName` let a caller keep its own look — the quiz + * player's menu carries a feedback form and a code badge, which are its + * business and not this component's. A caller whose items are plain commands + * can pass a function as its children and be handed `close`. + */ +export default function MoreMenu({ + label = 'More options', className = '', menuClassName = '', children, +}) { + const [open, setOpen] = useState(false) + const wrap = useRef(null) + + useEffect(() => { + if (!open) return undefined + const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) } + const onKey = e => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', away) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', away) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + return ( +
+ + {/* Not closed on any click inside: the player's menu holds a feedback + form and a share dialog, and a menu that vanishes as you type in it + is worse than one left open. A caller that wants it shut says so. */} + {open && ( +
+ {typeof children === 'function' ? children(() => setOpen(false)) : children} +
+ )} +
+ ) +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 1397672..9f61b8a 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -232,6 +232,9 @@ export default function Navbar({ onSignIn, onRegister }) { // you have sat and the reading of how it went — so they are one page. { to: '/sessions', label: 'Sessions' }, { to: '/question-bank', label: 'Qbank' }, + // What you have put aside: the star and any libraries you keep. Beside the + // bank because that is where things get put aside from. + { to: '/collections', label: 'Collections' }, ...(canManageQuestions ? [{ to: '/questions/manage', label: 'Questions' }, { to: '/media', label: 'Images' }, { to: '/editorial', label: 'Editorial' }] : []), diff --git a/frontend/src/pages/CollectionsPage.css b/frontend/src/pages/CollectionsPage.css new file mode 100644 index 0000000..36a1050 --- /dev/null +++ b/frontend/src/pages/CollectionsPage.css @@ -0,0 +1,109 @@ +.col-page { max-width: 1100px; margin: 0 auto; padding: 0 4px 48px; } + +.col-head h1 { margin: 0 0 6px; font-size: 1.6rem; font-weight: 700; } +.col-head p { margin: 0 0 20px; color: var(--text-muted); font-size: 0.92rem; } +.col-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--wrong-fg); } + +/* Search, sort and the view switch on one line, wrapping rather than + squeezing: on a phone the search takes the row and the rest the next. */ +.col-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } +.col-search { flex: 1 1 240px; min-width: 0; } +.col-search, .col-sort select, .col-new input { + padding: 9px 12px; font: inherit; font-size: 0.88rem; + border: 1px solid var(--border); border-radius: 9px; + background: var(--input-bg); color: var(--text); +} +.col-dir { + width: 38px; height: 38px; font-size: 1rem; cursor: pointer; + border: 1px solid var(--border); border-radius: 9px; + background: var(--card-bg); color: var(--text-muted); +} +.col-views { display: flex; gap: 4px; padding: 3px; border-radius: 9px; background: var(--border); } +.col-views button { + border: 0; background: none; color: var(--text-muted); cursor: pointer; + font: inherit; font-size: 0.8rem; padding: 6px 11px; border-radius: 7px; +} +.col-views button[aria-selected='true'] { background: var(--card-bg); color: var(--text); font-weight: 600; } + +.col-new { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; } +.col-new input { flex: 1 1 240px; min-width: 0; } + +.col-count { margin: 0 0 12px; font-size: 0.82rem; color: var(--text-muted); } +.col-empty { color: var(--text-muted); font-size: 0.9rem; } + +/* ── Card view ────────────────────────────────────────────────────── */ +.col-cards { + list-style: none; margin: 0; padding: 0; + display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; +} +.col-card { + padding: 15px 16px 16px; background: var(--card-bg); + border: 1px solid var(--border); border-radius: 12px; +} +.col-card.is-fixed { border-color: var(--primary); } +.col-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; } +.col-card-name, .col-linkish { + padding: 0; border: 0; background: none; text-align: left; cursor: pointer; + font: inherit; font-size: 1rem; font-weight: 650; color: var(--text); + overflow-wrap: anywhere; +} +.col-linkish { font-size: 0.86rem; } +.col-card-name:hover, .col-linkish:hover { color: var(--primary); } +.col-card-meta { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + margin: 10px 0 4px; font-size: 0.84rem; color: var(--text-muted); +} +.col-private { + padding: 2px 8px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; + text-transform: uppercase; border-radius: 20px; + background: var(--bg); color: var(--text-subtle); border: 1px solid var(--border); +} +.col-card-when { margin: 0; font-size: 0.76rem; color: var(--text-subtle); } + +/* ── Table view ───────────────────────────────────────────────────── */ +.col-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; } +.col-table { width: 100%; border-collapse: collapse; font-size: 0.86rem; background: var(--card-bg); } +.col-table th, .col-table td { padding: 11px 14px; text-align: left; white-space: nowrap; } +.col-table th { + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--text-subtle); border-bottom: 1px solid var(--border); +} +.col-table tbody tr + tr td { border-top: 1px solid var(--border); } +.col-table a { color: var(--text); text-decoration: none; font-weight: 600; } +.col-table a:hover { color: var(--primary); } +.col-num { text-align: right; } + +/* ── Renaming ─────────────────────────────────────────────────────── */ +.col-overlay { + position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; + padding: 20px; background: rgba(15, 23, 42, 0.45); +} +.col-dialog { + width: min(420px, 100%); padding: 20px; border-radius: 14px; + background: var(--card-bg); border: 1px solid var(--border); +} +.col-dialog h2 { margin: 0 0 12px; font-size: 1.05rem; font-weight: 650; } +.col-dialog input { + width: 100%; padding: 10px 12px; font: inherit; + border: 1px solid var(--border); border-radius: 9px; + background: var(--input-bg); color: var(--text); +} +.col-dialog-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } + +/* ── What is on one shelf ───────────────────────────────────────────── + Opened in place: there is no browsable question list to send anyone to, + and a link that goes nowhere is worse than no link. */ +.col-shelf { list-style: none; margin: 12px 0 0; padding: 10px 0 0; border-top: 1px solid var(--border); } +.col-shelf li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; font-size: 0.82rem; } +.col-shelf-stem { + flex: 1; min-width: 0; color: var(--text-muted); line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.col-shelf-drop { + flex-shrink: 0; border: 0; background: none; cursor: pointer; + color: var(--text-subtle); font-size: 0.9rem; padding: 0 2px; line-height: 1.5; +} +.col-shelf-drop:hover { color: var(--wrong-fg); } +.col-shelf-note { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--border); font-size: 0.82rem; color: var(--text-subtle); } +.col-open-row td { background: var(--bg); white-space: normal; } +.col-open-row .col-shelf, .col-open-row .col-shelf-note { margin-top: 0; border-top: 0; padding-top: 0; } diff --git a/frontend/src/pages/CollectionsPage.jsx b/frontend/src/pages/CollectionsPage.jsx new file mode 100644 index 0000000..c2d1a64 --- /dev/null +++ b/frontend/src/pages/CollectionsPage.jsx @@ -0,0 +1,327 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import api from '../api/client' +import MoreMenu from '../components/MoreMenu' +import './CollectionsPage.css' + +const SORTS = [ + { key: 'used', label: 'Last used' }, + { key: 'created', label: 'Date created' }, + { key: 'title', label: 'Name' }, + { key: 'size', label: 'Number of questions' }, +] + +const when = (value) => (value + ? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) + : '—') + +/** The Favorites row is not a collection; it is the star, given a place to live. */ +const FAVORITES = 'favorites' + +/** What is on one shelf, opened in place. */ +function Shelf({ rows, onDrop }) { + if (rows === null) return

Loading…

+ if (rows.length === 0) return

Nothing saved here yet.

+ return ( +
    + {rows.map(question => ( +
  • + + {question.question_text} + + +
  • + ))} +
+ ) +} + +/** + * Everything a learner has put aside: the star, and the libraries they made. + * + * Sorted by when each was last used rather than when it was made, because the + * order things were created in is nobody's mental model of their own shelf. + * A library nobody has opened falls back to its age — it is newer to the + * learner than it is to the database. + */ +export default function CollectionsPage() { + const [rows, setRows] = useState(null) + const [favorites, setFavorites] = useState([]) + const [view, setView] = useState(() => localStorage.getItem('collections.view') || 'card') + const [sort, setSort] = useState('used') + const [descending, setDescending] = useState(true) + const [query, setQuery] = useState('') + const [error, setError] = useState('') + const [naming, setNaming] = useState(null) // { id, title } while renaming + // Which shelf is open, and what is on it. Opened in place: there is no + // browsable question list to send anyone to, and a link that goes nowhere is + // worse than no link. + const [openId, setOpenId] = useState(null) + const [contents, setContents] = useState(null) + const [creating, setCreating] = useState('') + const [busy, setBusy] = useState(false) + const navigate = useNavigate() + + const load = useCallback(() => { + Promise.all([ + api.get('/collections/').catch(() => ({ data: [] })), + api.get('/favorites').catch(() => ({ data: [] })), + ]).then(([collections, stars]) => { + setRows(Array.isArray(collections.data) ? collections.data : []) + setFavorites(Array.isArray(stars.data) ? stars.data : []) + }).catch(() => setError('Could not load your collections')) + }, []) + + useEffect(() => { load() }, [load]) + + useEffect(() => { localStorage.setItem('collections.view', view) }, [view]) + + const all = useMemo(() => { + if (rows === null) return null + const star = { + id: FAVORITES, title: 'Favorites', question_count: favorites.length, + created_at: null, last_used_at: null, private: true, fixed: true, + } + return [star, ...rows] + }, [rows, favorites]) + + const shown = useMemo(() => { + if (!all) return null + const needle = query.trim().toLowerCase() + const matched = needle + ? all.filter(row => row.title.toLowerCase().includes(needle) + || when(row.created_at).toLowerCase().includes(needle) + || when(row.last_used_at).toLowerCase().includes(needle)) + : all + const key = { + used: row => new Date(row.last_used_at || row.created_at || 0).getTime(), + created: row => new Date(row.created_at || 0).getTime(), + title: row => row.title.toLowerCase(), + size: row => row.question_count, + }[sort] + const sorted = [...matched].sort((a, b) => { + const [x, y] = [key(a), key(b)] + if (x === y) return a.title.localeCompare(b.title) + return (x > y ? 1 : -1) * (descending ? -1 : 1) + }) + // Favorites always leads: it is the one shelf nobody made and everybody has. + return [...sorted.filter(r => r.fixed), ...sorted.filter(r => !r.fixed)] + }, [all, query, sort, descending]) + + const run = async (fn, failure) => { + setBusy(true); setError('') + try { await fn(); load() } + catch (err) { + const detail = err?.response?.data?.detail + setError(typeof detail === 'string' ? detail : failure) + } finally { setBusy(false) } + } + + const create = () => run(async () => { + const title = creating.trim() + if (!title) return + await api.post('/collections/', { title }) + setCreating('') + }, 'Could not create that collection') + + const rename = () => run(async () => { + const title = naming.title.trim() + if (!title) return + await api.patch(`/collections/${naming.id}`, { title }) + setNaming(null) + }, 'Could not rename that collection') + + const remove = (row) => run( + () => api.delete(`/collections/${row.id}`), 'Could not delete that collection') + + /** Sit the questions in one shelf, as a session of their own. */ + const practise = async (row) => { + setError('') + try { + const ids = (await questionsIn(row)).map(q => q.id) + if (!ids.length) { setError(`${row.title} has no questions in it yet.`); return } + const res = await api.post('/questions/builder', { + title: `${row.title} — practice`, category_ids: [], state: 'all', + count: Math.min(ids.length, 200), mode: 'learning', explicit_ids: ids, + }) + navigate(`/study/${res.data.id}?start=1`) + } catch { + setError('Could not start a session from that collection') + } + } + + const questionsIn = useCallback(async (row) => (row.id === FAVORITES + ? (await api.get('/questions/bank', { params: { favorites_only: true, limit: 200 } })) + .data.questions.map(q => ({ id: q.id, question_text: q.question_text })) + : (await api.get(`/collections/${row.id}/questions`)).data), []) + + const toggle = async (row) => { + if (openId === row.id) { setOpenId(null); setContents(null); return } + setOpenId(row.id) + setContents(null) + setError('') + try { setContents(await questionsIn(row)) } + catch { setContents([]); setError(`Could not open ${row.title}`) } + } + + const drop = (row, questionId) => run(async () => { + if (row.id === FAVORITES) await api.delete(`/favorites/${questionId}`) + else await api.delete(`/collections/${row.id}/questions/${questionId}`) + setContents(list => (list || []).filter(q => q.id !== questionId)) + }, 'Could not take that question out') + + const menu = (row) => (close) => ( + <> + + + {!row.fixed && ( + <> + + + + )} + + ) + + return ( +
+
+

Collections

+

Your saved questions: the star, and any libraries you keep.

+
+ + {error &&

{error}

} + +
+ setQuery(e.target.value)} /> + + + + +
+ {[['card', 'Card view'], ['table', 'Table view']].map(([value, label]) => ( + + ))} +
+
+ +
{ e.preventDefault(); create() }}> + setCreating(e.target.value)} /> + +
+ + {shown === null ?
: ( + <> +

+ Showing: {shown.length} collection{shown.length === 1 ? '' : 's'} + {query.trim() && all.length !== shown.length && ` of ${all.length}`} +

+ + {shown.length === 0 ? ( +

Nothing matches “{query.trim()}”.

+ ) : view === 'card' ? ( +
    + {shown.map(row => ( +
  • +
    + + {menu(row)} +
    +

    + {row.question_count} question{row.question_count === 1 ? '' : 's'} + {row.private && Private} +

    +

    + {row.fixed ? 'Always here' : `Last used ${when(row.last_used_at)}`} +

    + {openId === row.id && drop(row, id)} />} +
  • + ))} +
+ ) : ( +
+ + + + + + + + + + + + + {shown.flatMap(row => [ + + + + + + + + , + openId === row.id && ( + + + + ), + ])} + +
NameQuestionsVisibilityCreatedLast usedOptions
+ + {row.question_count}{row.private ? 'Private' : 'Shared'}{row.fixed ? '—' : when(row.created_at)}{row.fixed ? '—' : when(row.last_used_at)} + {menu(row)} +
drop(row, id)} />
+
+ )} + + )} + + {naming && ( +
e.target === e.currentTarget && setNaming(null)}> +
{ e.preventDefault(); rename() }}> +

Rename collection

+ setNaming(n => ({ ...n, title: e.target.value }))} /> +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/CollectionsPage.test.jsx b/frontend/src/pages/CollectionsPage.test.jsx new file mode 100644 index 0000000..1dabeeb --- /dev/null +++ b/frontend/src/pages/CollectionsPage.test.jsx @@ -0,0 +1,152 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, expect, it, vi } from 'vitest' +import CollectionsPage from './CollectionsPage' +import api from '../api/client' + +vi.mock('../api/client', () => ({ + default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() }, +})) + +const LIBRARIES = [ + { id: 1, title: 'Cardiology misses', question_count: 12, private: true, + created_at: '2026-01-04T09:00:00', last_used_at: '2026-09-10T09:00:00' }, + { id: 2, title: 'Airway emergencies', question_count: 3, private: true, + created_at: '2026-08-01T09:00:00', last_used_at: null }, +] + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/collections/' ? LIBRARIES : url === '/favorites' ? [7, 8, 9] : [], + })) + api.post.mockResolvedValue({ data: { id: 55 } }) + api.patch.mockResolvedValue({ data: {} }) + api.delete.mockResolvedValue({}) +}) + +const mount = () => render() + +it('leads with Favorites and counts what is on the shelf', async () => { + mount() + expect(await screen.findByText('Showing: 3 collections')).toBeInTheDocument() + const cards = document.querySelectorAll('.col-card') + // The star is nobody's creation and everybody's, so it comes first. + expect(within(cards[0]).getByText(/Favorites/)).toBeInTheDocument() + expect(within(cards[0]).getByText('3 questions')).toBeInTheDocument() + expect(within(cards[1]).getByText(/Cardiology misses/)).toBeInTheDocument() +}) + +it('sorts by last used, and a library nobody has opened falls back to its age', async () => { + mount() + await screen.findByText('Showing: 3 collections') + const names = [...document.querySelectorAll('.col-card-name')].map(n => n.textContent) + expect(names).toEqual(['★ Favorites', 'Cardiology misses', 'Airway emergencies']) + + // By name, ascending: the direction control means what it says. + await userEvent.selectOptions(screen.getByLabelText('Sort by'), 'title') + await userEvent.click(screen.getByRole('button', { name: /Sorted newest first/ })) + const byName = [...document.querySelectorAll('.col-card-name')].map(n => n.textContent) + expect(byName).toEqual(['★ Favorites', 'Airway emergencies', 'Cardiology misses']) +}) + +it('searches by name and says how many of how many', async () => { + mount() + await screen.findByText('Showing: 3 collections') + await userEvent.type(screen.getByLabelText('Search collections'), 'airway') + expect(await screen.findByText('Showing: 1 collection of 3')).toBeInTheDocument() + expect(screen.queryByText(/Cardiology misses/)).not.toBeInTheDocument() +}) + +it('shows the same shelves as a table, and remembers which view was chosen', async () => { + const { unmount } = mount() + await screen.findByText('Showing: 3 collections') + await userEvent.click(screen.getByRole('tab', { name: 'Table view' })) + const table = screen.getByRole('table') + expect(within(table).getAllByRole('row')).toHaveLength(4) // header + three + expect(within(table).getAllByText('Private')).toHaveLength(3) + + unmount() + mount() + expect(await screen.findByRole('table')).toBeInTheDocument() +}) + +it('renames a library, and will not rename Favorites', async () => { + mount() + await screen.findByText('Showing: 3 collections') + const cards = document.querySelectorAll('.col-card') + // Favorites is not a collection, so it has nothing to rename or delete. + await userEvent.click(within(cards[0]).getByRole('button', { name: 'Options for Favorites' })) + expect(screen.queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument() + await userEvent.keyboard('{Escape}') + + await userEvent.click(within(cards[1]).getByRole('button', { name: 'Options for Cardiology misses' })) + await userEvent.click(screen.getByRole('button', { name: 'Rename' })) + const field = screen.getByLabelText('Collection name') + await userEvent.clear(field) + await userEvent.type(field, 'Cardiology') + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/collections/1', { title: 'Cardiology' })) +}) + +it('opens a shelf in place and takes a question back out', async () => { + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/collections/' ? LIBRARIES + : url === '/favorites' ? [7, 8, 9] + : url === '/collections/1/questions' + ? [{ id: 11, question_text: 'A 2-year-old with a barking cough.' }] : [], + })) + mount() + await screen.findByText('Showing: 3 collections') + await userEvent.click(screen.getByRole('button', { name: 'Cardiology misses' })) + expect(await screen.findByText('A 2-year-old with a barking cough.')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Take question 11 out' })) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/collections/1/questions/11')) +}) + +it('takes a favourite off the star rather than out of a library', async () => { + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/collections/' ? LIBRARIES + : url === '/favorites' ? [7] + : url === '/questions/bank' + ? { questions: [{ id: 7, question_text: 'A neonate with bilious vomiting.' }] } : [], + })) + mount() + await screen.findByText('Showing: 3 collections') + await userEvent.click(screen.getByRole('button', { name: 'Favorites' })) + await userEvent.click(await screen.findByRole('button', { name: 'Take question 7 out' })) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/favorites/7')) +}) + +it('starts a session from the questions in a shelf', async () => { + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/collections/' ? LIBRARIES + : url === '/favorites' ? [7, 8, 9] + : url === '/collections/1/questions' ? [{ id: 11 }, { id: 12 }] : [], + })) + mount() + await screen.findByText('Showing: 3 collections') + const card = [...document.querySelectorAll('.col-card')][1] + await userEvent.click(within(card).getByRole('button', { name: 'Options for Cardiology misses' })) + await userEvent.click(screen.getByRole('button', { name: 'Practise these' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/builder', + expect.objectContaining({ explicit_ids: [11, 12], count: 2, mode: 'learning' }))) +}) + +it('says so rather than starting an empty session', async () => { + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/collections/' ? LIBRARIES + : url === '/favorites' ? [] + : url === '/questions/bank' ? { questions: [] } : [], + })) + mount() + await screen.findByText('Showing: 3 collections') + const card = document.querySelectorAll('.col-card')[0] + await userEvent.click(within(card).getByRole('button', { name: 'Options for Favorites' })) + await userEvent.click(screen.getByRole('button', { name: 'Practise these' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Favorites has no questions in it yet.') + expect(api.post).not.toHaveBeenCalled() +}) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 3b7ffc9..c59cc4f 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -12,6 +12,7 @@ import useAwayDetector from '../hooks/useAwayDetector' import FigureStrip from '../components/FigureStrip' import FeedbackForm from '../components/FeedbackForm' import ShareSession from '../components/ShareSession' +import MoreMenu from '../components/MoreMenu' import '../components/Feedback.css' import QuizTools, { QuizDialog } from '../components/QuizTools' import './QuizPlayer.css' @@ -315,28 +316,10 @@ function ShareLinkBadge({ quiz, onShareChanged }) { } /** Occasional session actions, folded away until asked for. */ +/** The player's ⋯ menu: the shared one, keeping its own look. */ function MoreActions({ children }) { - const [open, setOpen] = useState(false) - const wrap = useRef(null) - - useEffect(() => { - if (!open) return undefined - const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) } - const onKey = e => { if (e.key === 'Escape') setOpen(false) } - document.addEventListener('mousedown', away) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', away) - document.removeEventListener('keydown', onKey) - } - }, [open]) - return ( -
- - {open &&
{children}
} -
+ {children} ) }