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} ) }