feat: the objective is enforced, folders are made where you are, and the
landing page describes this product An objective is now required to build a session, not only asked for in the interface — the interface asks, and this is the same rule where it cannot be walked past. Only where there is something to choose: a deployment with no exams, and the first administrator of a fresh one, must still be able to build a session. A rule that locks an empty site is not a rule, it is a fault. Saving a question into a folder is one box that searches what you have and offers to make what you do not. It used to say "make one in the question bank" and leave you to go and do it, which means leaving the question you were reading and coming back to find your place. A name that already exists exactly is not offered twice; a partial match offers both, because wanting a narrower folder called "cardio" is not the same as wanting the one called "Cardiology misses". The landing page is rebuilt. Its copy described a product from months ago — "upload a PDF, AI extracts questions", which is one feature of many now — and it was 568 lines of inline style objects, which cannot express a hover, a media query or a keyframe. The figures come from /api/public/stats and count up; a failed fetch renders the section without them rather than showing noughts, which would be a lie about an empty bank. Motion is CSS and SVG, and prefers-reduced-motion turns all of it off — including forcing the scroll-revealed elements visible, since a hidden element with its animation removed is how respecting that setting turns into a blank page. Two smaller ones from the screenshots: the collections shelf is boxed rather than scrolling past everything else on the page, and its rows no longer carry the entire stem — lab tables and all — in a native tooltip that covered half the screen and could not be dismissed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
77c09db057
commit
2b5262d255
11 changed files with 1173 additions and 321 deletions
|
|
@ -356,7 +356,31 @@ def adaptive_select(db, user, count, category_ids, state, difficulty):
|
|||
return selected
|
||||
|
||||
|
||||
def require_objective(db, user) -> None:
|
||||
"""Refuse to build a session for somebody who has not said what they study.
|
||||
|
||||
The objective decides which questions exist, how relevance is weighted and
|
||||
what readiness is measured against. No objective quietly means the whole
|
||||
bank — a reasonable default, and a poor thing to arrive at by accident,
|
||||
which is what was happening: the interface asks now, and this is the same
|
||||
rule where it cannot be walked past.
|
||||
|
||||
Only where there is something to choose. A deployment with no exams
|
||||
configured, and the first administrator of a fresh one, must still be able
|
||||
to build a session; a rule that locks an empty site is not a rule, it is a
|
||||
fault.
|
||||
"""
|
||||
if getattr(user, "active_exam_id", None):
|
||||
return
|
||||
from app.models.exam import Exam
|
||||
|
||||
if not db.query(Exam.id).filter(Exam.is_active == 1).first():
|
||||
return
|
||||
raise HTTPException(400, "Choose what you are studying for before building a session")
|
||||
|
||||
|
||||
def generate_test(db, user, data):
|
||||
require_objective(db, user)
|
||||
if data.algorithm == "blueprint":
|
||||
return _blueprint_test(db, user, data)
|
||||
if data.algorithm == "adaptive":
|
||||
|
|
|
|||
|
|
@ -453,3 +453,43 @@ class ExamClockTests(unittest.TestCase):
|
|||
time_limit_minutes = 15
|
||||
|
||||
self.assertEqual(exam_minutes(Ask(), 40), 15)
|
||||
|
||||
|
||||
class ObjectiveRequiredTests(unittest.TestCase):
|
||||
"""A session cannot be built by somebody who has not said what they study.
|
||||
|
||||
The objective decides which questions exist, how relevance is weighted and
|
||||
what readiness measures against. The interface asks; this is the same rule
|
||||
where it cannot be walked past.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.bank = BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.client = self.bank.client
|
||||
self.db = self.bank.db
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def offer(self):
|
||||
from app.models.exam import Exam
|
||||
self.db.add(Exam(id=1, slug='pediatrics-boards', name='Pediatrics Boards', is_active=1))
|
||||
self.db.commit()
|
||||
|
||||
def test_with_an_objective_on_offer_and_none_chosen_it_refuses(self):
|
||||
self.offer()
|
||||
response = self.bank.generate(is_shared=True, category_ids=[1])
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertIn('studying for', response.json()['detail'])
|
||||
|
||||
def test_choosing_one_lets_it_through(self):
|
||||
self.offer()
|
||||
self.bank.owner.active_exam_id = 1
|
||||
self.db.commit()
|
||||
self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200)
|
||||
|
||||
def test_a_site_with_no_objectives_is_not_locked(self):
|
||||
# A rule that locks an empty deployment is not a rule, it is a fault:
|
||||
# the first administrator has nothing to choose from yet.
|
||||
self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200)
|
||||
|
|
|
|||
52
docs/TODO.md
52
docs/TODO.md
|
|
@ -332,12 +332,12 @@ Analysis**, which has three tabs.
|
|||
|
||||
### Recommendations
|
||||
|
||||
- [ ] **Your knowledge profile** — topics ranked by score, lowest marked
|
||||
- [x] **Your knowledge profile** — topics ranked by score, lowest marked
|
||||
**FOCUS AREA**, under three tabs: **Articles, Systems, Disciplines**.
|
||||
Columns: Topic, score, **Relevance**, Status, Action. A row expands to
|
||||
questions completed with a bar, answered-correctly with a
|
||||
correct/hints/incorrect bar, and a Start Qbank button for that topic.
|
||||
- [ ] **Relevance is the ABP content specification weight.** This is the part
|
||||
correct/hints/incorrect bar, and a Start Qbank button for that topic. — done 2026-09-12 — paginated, with the three axes and expandable rows.
|
||||
- [x] **Relevance is the ABP content specification weight.** This is the part
|
||||
we can do properly and AMBOSS cannot explain: a topic's relevance is the
|
||||
share of the real paper its domain accounts for, which
|
||||
`exam_blueprints.weight` already holds. Cardiology at 5% and
|
||||
|
|
@ -345,7 +345,7 @@ Analysis**, which has three tabs.
|
|||
say so from the board's own numbers rather than from a guess.
|
||||
|
||||
### Session Analysis
|
||||
|
||||
— done — exam_blueprints.weight drives the Relevance column and now adaptive selection too.
|
||||
- [x] **A rail of latest sessions** — done. `AnalysisShell` owns the rail and
|
||||
the session list for both views. The session's recommendations now carry
|
||||
the Articles / Disciplines / Systems switch too, from
|
||||
|
|
@ -368,13 +368,13 @@ Analysis**, which has three tabs.
|
|||
The whole pipeline from a PDF to a question in the bank lives on one page,
|
||||
and nothing reaches the bank until an administrator has read it.
|
||||
|
||||
- [ ] **Move the PDFs into Tools.** Upload, the document list and extraction
|
||||
- [x] **Move the PDFs into Tools.** Upload, the document list and extraction
|
||||
all move off their own pages and onto the Tools page, because they are
|
||||
one job and were three places.
|
||||
- [ ] **Nextcloud is the administrator's alone.** Nobody else connects an
|
||||
one job and were three places. — done — upload, the document list and extraction all live on /tools.
|
||||
- [x] **Nextcloud is the administrator's alone.** Nobody else connects an
|
||||
account; it is an import path for whoever loads the corpus, not a
|
||||
per-learner integration. It moves out of everyone's Settings.
|
||||
- [ ] **Review and promote, on the same page.** A run lands as a batch of
|
||||
per-learner integration. It moves out of everyone's Settings. — done.
|
||||
- [x] **Review and promote, on the same page.** A run lands as a batch of
|
||||
drafts — already built, `draft_batches` / `draft_questions`, with their
|
||||
own sequence so nothing takes a question id early. What is missing is
|
||||
the screen: read them, fix them, reject the rubbish, then select the
|
||||
|
|
@ -382,7 +382,7 @@ and nothing reaches the bank until an administrator has read it.
|
|||
moment a `Question` is created.
|
||||
|
||||
## Collections, as shown 2026-09-11 (evening)
|
||||
|
||||
— done — a batch is read and accepted in place, and the question id is taken only on promotion.
|
||||
- [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
|
||||
|
|
@ -513,25 +513,25 @@ needs are `conversations` and `messages`.
|
|||
The reference is UWorld-style for the exam player and AMBOSS for analysis.
|
||||
|
||||
### Exam player chrome
|
||||
- [ ] **Top bar**: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m
|
||||
- [x] **Top bar** — done 2026-09-12.: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m
|
||||
/ Next in the middle, Lab Values · Notes · Calculator · Settings at the
|
||||
right.
|
||||
- [ ] **Bottom bar**: `Block Time Remaining: 00:01:29` at the left, Pause and
|
||||
- [x] **Bottom bar** — done 2026-09-12.: `Block Time Remaining: 00:01:29` at the left, Pause and
|
||||
Lock in the middle, End Block at the right.
|
||||
- [ ] **One button, not two.** A question with no answer offers Skip *and*
|
||||
- [x] **One button, not two.** — done 2026-09-12. A question with no answer offers Skip *and*
|
||||
Next today. It is one boxed control: one button.
|
||||
- [ ] **Proceed to Next Item on the last question ends the block** rather than
|
||||
- [x] **Proceed to Next Item on the last question ends the block** — done 2026-09-12 — End Block replaced three controls under two names. rather than
|
||||
doing nothing.
|
||||
- [ ] **Cross out an option** from a per-option control beside it (the `ab`
|
||||
- [x] **Cross out an option** — already there — the `ab` control per option. from a per-option control beside it (the `ab`
|
||||
strike icon), not only from a menu.
|
||||
- [ ] **Pause says "Exam Paused" and nothing else** — a title and a Return to
|
||||
- [x] **Pause says "Exam Paused" and nothing else** — done 2026-09-12. — a title and a Return to
|
||||
exam button. No warning about real exams; that is AMBOSS's disclaimer,
|
||||
not ours.
|
||||
- [ ] **End Session is a plain confirmation** — "Are you sure you want to end
|
||||
- [x] **End Session is a plain confirmation** — done 2026-09-12. — "Are you sure you want to end
|
||||
this session?", End Session / Cancel.
|
||||
- [ ] **Right-click removes a highlight**, and the yellow itself needs fixing.
|
||||
- [ ] **The rail shows numbers while sitting an exam and shortened stems in
|
||||
review.** It is numbers in both today.
|
||||
- [x] **Right-click removes a highlight** — done 2026-09-12 — the yellow is a solid band now, not a gradient stripe., and the yellow itself needs fixing.
|
||||
- [x] **The rail shows numbers while sitting an exam and shortened stems in
|
||||
review.** — done 2026-09-12. It is numbers in both today.
|
||||
|
||||
### Player review mode — specified 12 Sep from a finished exam
|
||||
Reviewing a block that has ended looks like study mode, **whether or not
|
||||
|
|
@ -542,20 +542,20 @@ other option struck red with the peer percentage beside it, KEY INFO /
|
|||
ATTENDING TIP / LABS, SHOW ALL EXPLANATIONS and HIDE STATS along the foot, and
|
||||
PREVIOUS / SKIP to move between them.
|
||||
|
||||
- [ ] **"Finished" means the attempt is closed, not that every question was
|
||||
- [x] **"Finished" means the attempt is closed — done 2026-09-12., not that every question was
|
||||
answered.** The flag in QuizPage is `answeredCount >= totalCount`, which
|
||||
is wrong for exactly this case: a block that timed out with nothing
|
||||
answered is over, and it still shows numbers and hides explanations.
|
||||
- [ ] **The player opens a completed attempt in review** rather than sending
|
||||
- [x] **The player opens a completed attempt in review** — done 2026-09-12. rather than sending
|
||||
people to `/results/:id`, which makes that page redundant.
|
||||
|
||||
### Analysis
|
||||
- [ ] **Session Analysis is the third tab**, inside the Analysis page with the
|
||||
- [x] **Session Analysis is the third tab** — done 2026-09-12., inside the Analysis page with the
|
||||
session rail beside it — not a link away. Four figures, the donut, and
|
||||
Study recommendations under Articles / Disciplines / Systems.
|
||||
- [ ] **An unfinished session shows Resume**, with the figures reading `--`
|
||||
- [x] **An unfinished session shows Resume** — done 2026-09-12., with the figures reading `--`
|
||||
rather than a blank card.
|
||||
- [ ] **Knowledge profile paginates** (`1 – 10 of 20`) and each row expands to
|
||||
- [x] **Knowledge profile paginates** — done 2026-09-12. (`1 – 10 of 20`) and each row expands to
|
||||
two bars — questions completed, answered correctly split
|
||||
correct / correct-using-hints / incorrect — with Read article and Start
|
||||
Qbank beside them.
|
||||
|
|
@ -563,7 +563,7 @@ PREVIOUS / SKIP to move between them.
|
|||
### Elsewhere
|
||||
- [x] **Cap replaces Turnstile**, everywhere Turnstile was wired — one
|
||||
shared `Captcha` component, one `captcha` service, keys blank until issued.
|
||||
- [ ] **The session rail scrolls** once it holds more than a screenful.
|
||||
- [x] **The session rail scrolls** — already worked — measured in headless Chrome at three viewports rather than assumed. once it holds more than a screenful.
|
||||
|
||||
## Done this session
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,13 @@
|
|||
/* ── 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 {
|
||||
list-style: none; margin: 12px 0 0; padding: 10px 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
/* Boxed. Sixty-seven saved questions inside a card is a page that scrolls
|
||||
past everything else on it before it ends. */
|
||||
max-height: 320px; overflow-y: auto; overscroll-behavior: contain;
|
||||
}
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ function Shelf({ rows, onDrop }) {
|
|||
<ul className="col-shelf">
|
||||
{rows.map(question => (
|
||||
<li key={question.id}>
|
||||
<span className="col-shelf-stem" title={question.question_text}>
|
||||
{question.question_text}
|
||||
</span>
|
||||
{/* No title attribute. It carried the whole stem — lab tables and
|
||||
all — into a native tooltip that covered half the page and could
|
||||
not be dismissed or scrolled. Two lines is the excerpt; the
|
||||
question itself is a session away. */}
|
||||
<span className="col-shelf-stem">{question.question_text}</span>
|
||||
<button type="button" className="col-shelf-drop" aria-label={`Take question ${question.id} out`}
|
||||
onClick={() => onDrop(question.id)}>✕</button>
|
||||
</li>
|
||||
|
|
|
|||
347
frontend/src/pages/LandingPage.css
Normal file
347
frontend/src/pages/LandingPage.css
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/* The signed-out splash. Everything here used to be an inline style object,
|
||||
which is why the page could not hover, could not respond to a narrow
|
||||
window, and could not move: none of those three can be expressed in the
|
||||
`style` attribute at all.
|
||||
|
||||
Colours are theme tokens without exception. The site ships a cool theme and
|
||||
a warm one, and a literal hex here would look deliberate in one of them and
|
||||
broken in the other. The only literals are white and black at low alpha,
|
||||
used exclusively over --navbar-bg, which is dark in both themes. */
|
||||
|
||||
.lp-page { min-height: 100dvh; background: var(--bg); color: var(--text); }
|
||||
.lp-page .navbar { margin-bottom: 0; }
|
||||
|
||||
/* Sections alternate between the page ground and the card surface so the eye
|
||||
has somewhere to rest between them, rather than one unbroken column. */
|
||||
.lp-section { padding: 88px 24px; }
|
||||
.lp-section-raised { background: var(--card-bg); border-block: 1px solid var(--border); }
|
||||
.lp-section-dark { background: var(--navbar-bg); color: var(--navbar-fg); }
|
||||
.lp-section-dark + .lp-section-dark { border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.lp-inner { max-width: 1080px; margin: 0 auto; }
|
||||
.lp-inner-narrow { max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.lp-eyebrow {
|
||||
display: inline-block; margin-bottom: 20px; padding: 4px 13px;
|
||||
border: 1px solid var(--primary); border-radius: 999px;
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: var(--primary);
|
||||
}
|
||||
.lp-section-dark .lp-eyebrow { border-color: rgba(255,255,255,0.28); color: inherit; opacity: 0.8; }
|
||||
|
||||
.lp-head { text-align: center; margin-bottom: 48px; }
|
||||
.lp-head h2 { font-size: clamp(1.5rem, 3vw, 2.1rem); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 10px; }
|
||||
.lp-head p { margin: 0 auto; max-width: 560px; color: var(--text-muted); font-size: 1rem; line-height: 1.65; }
|
||||
.lp-section-dark .lp-head p { color: rgba(255,255,255,0.66); }
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.lp-hero { position: relative; overflow: hidden; padding: 104px 24px 92px; text-align: center; }
|
||||
|
||||
/* Two soft blooms of the theme's own accent, drifting slowly behind the
|
||||
text. Tinting with --primary rather than a fixed blue is what keeps the
|
||||
warm theme warm; the blur is what keeps it from reading as a shape. */
|
||||
.lp-hero::before,
|
||||
.lp-hero::after {
|
||||
content: ''; position: absolute; z-index: 0; border-radius: 50%;
|
||||
background: var(--primary); opacity: 0.13; filter: blur(90px);
|
||||
width: 460px; height: 460px; pointer-events: none;
|
||||
}
|
||||
.lp-hero::before { top: -180px; left: -120px; animation: lp-drift 22s ease-in-out infinite alternate; }
|
||||
.lp-hero::after { bottom: -220px; right: -140px; animation: lp-drift 26s ease-in-out infinite alternate-reverse; }
|
||||
|
||||
@keyframes lp-drift {
|
||||
from { transform: translate3d(0, 0, 0) scale(1); }
|
||||
to { transform: translate3d(70px, 40px, 0) scale(1.18); }
|
||||
}
|
||||
|
||||
.lp-hero-inner { position: relative; z-index: 1; max-width: 760px; margin: 0 auto; }
|
||||
|
||||
.lp-hero h1 {
|
||||
margin: 0 0 20px; font-size: clamp(2.1rem, 5.4vw, 3.4rem);
|
||||
font-weight: 800; line-height: 1.12; letter-spacing: -0.035em;
|
||||
}
|
||||
/* The second clause carries the accent. Both stops are theme tokens, so the
|
||||
sweep is legible on the warm ground and the cool one. */
|
||||
.lp-hero h1 em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(100deg, var(--primary), var(--text), var(--primary));
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent; color: transparent;
|
||||
animation: lp-sweep 9s linear infinite;
|
||||
}
|
||||
@keyframes lp-sweep {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: -220% 50%; }
|
||||
}
|
||||
|
||||
.lp-lead {
|
||||
margin: 0 auto 26px; max-width: 620px;
|
||||
font-size: 1.08rem; line-height: 1.7; color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* The one line on the page that moves of its own accord. It cycles the four
|
||||
rules the adaptive picker actually follows, so the motion is carrying
|
||||
information rather than decorating the absence of any. */
|
||||
.lp-rotator {
|
||||
display: flex; justify-content: center; align-items: baseline;
|
||||
gap: 8px; flex-wrap: wrap; margin: 0 0 32px;
|
||||
font-size: 0.95rem; color: var(--text-muted);
|
||||
}
|
||||
.lp-rotator-label { font-weight: 600; color: var(--text); }
|
||||
.lp-rotator-word {
|
||||
display: inline-block; padding: 3px 12px; border-radius: 999px;
|
||||
background: var(--option-sel-bg); border: 1px solid var(--option-sel-bd);
|
||||
color: var(--text); font-weight: 600;
|
||||
animation: lp-swap 0.45s ease both;
|
||||
}
|
||||
@keyframes lp-swap {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.lp-cta { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; }
|
||||
.lp-cta .btn { padding: 12px 26px; font-size: 1rem; border-radius: 10px; text-decoration: none; }
|
||||
.lp-cta-ghost {
|
||||
background: transparent; color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.18s, transform 0.18s;
|
||||
}
|
||||
.lp-cta-ghost:hover { border-color: var(--primary); transform: translateY(-1px); }
|
||||
|
||||
/* ── Figures ──────────────────────────────────────────────────────── */
|
||||
|
||||
.lp-figures {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px;
|
||||
}
|
||||
.lp-figure {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--card-radius, 12px); padding: 20px 18px; text-align: center;
|
||||
}
|
||||
.lp-section-raised .lp-figure { background: var(--bg); }
|
||||
.lp-figure-value {
|
||||
display: block; font-size: clamp(1.7rem, 4vw, 2.3rem);
|
||||
font-weight: 800; line-height: 1.1; letter-spacing: -0.03em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lp-figure-label {
|
||||
display: block; margin-top: 6px; font-size: 0.73rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted);
|
||||
}
|
||||
.lp-figures-missing {
|
||||
text-align: center; color: var(--text-muted); font-size: 0.92rem;
|
||||
border: 1px dashed var(--border); border-radius: var(--card-radius, 12px); padding: 20px;
|
||||
}
|
||||
|
||||
/* ── Feature cards ────────────────────────────────────────────────── */
|
||||
|
||||
.lp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 18px; }
|
||||
.lp-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--card-radius, 12px); padding: 24px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.lp-section-raised .lp-card { background: var(--bg); }
|
||||
.lp-card:hover { transform: translateY(-4px); border-color: var(--primary); box-shadow: var(--card-shadow); }
|
||||
.lp-card h3 { margin: 0 0 8px; font-size: 1.02rem; font-weight: 700; }
|
||||
.lp-card p { margin: 0; color: var(--text-muted); font-size: 0.9rem; line-height: 1.7; }
|
||||
.lp-card-mark {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px; margin-bottom: 14px;
|
||||
border-radius: 10px; background: var(--option-sel-bg);
|
||||
border: 1px solid var(--option-sel-bd); color: var(--primary);
|
||||
}
|
||||
.lp-card-mark svg { width: 20px; height: 20px; }
|
||||
.lp-card ul { margin: 12px 0 0; padding: 0; list-style: none; }
|
||||
.lp-card li {
|
||||
position: relative; padding-left: 16px; margin-top: 6px;
|
||||
color: var(--text-muted); font-size: 0.86rem; line-height: 1.6;
|
||||
}
|
||||
.lp-card li::before {
|
||||
content: ''; position: absolute; left: 0; top: 0.62em;
|
||||
width: 5px; height: 5px; border-radius: 50%; background: var(--primary);
|
||||
}
|
||||
|
||||
/* ── The dark honesty band ────────────────────────────────────────── */
|
||||
|
||||
.lp-claims { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 18px; }
|
||||
.lp-claim {
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px; padding: 20px; transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.lp-claim:hover { background: rgba(255,255,255,0.09); transform: translateY(-3px); }
|
||||
.lp-claim h3 { margin: 0 0 8px; font-size: 0.98rem; font-weight: 700; }
|
||||
.lp-claim p { margin: 0; font-size: 0.87rem; line-height: 1.7; color: rgba(255,255,255,0.66); }
|
||||
|
||||
/* ── Clinical tools ───────────────────────────────────────────────── */
|
||||
|
||||
.lp-split {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||
gap: 40px; align-items: center;
|
||||
}
|
||||
.lp-split h2 { font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 14px; }
|
||||
.lp-split a.btn { text-decoration: none; }
|
||||
.lp-split p { margin: 0 0 22px; font-size: 0.94rem; line-height: 1.75; color: rgba(255,255,255,0.66); }
|
||||
.lp-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr)); gap: 10px; }
|
||||
.lp-tile {
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px; padding: 14px; transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.lp-tile:hover { background: rgba(255,255,255,0.1); transform: translateY(-2px); }
|
||||
.lp-tile-label { font-weight: 700; font-size: 0.85rem; }
|
||||
.lp-tile-sub { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
|
||||
/* ── Contact ──────────────────────────────────────────────────────── */
|
||||
|
||||
.lp-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.lp-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 12px; }
|
||||
.lp-field label { display: block; margin-bottom: 5px; font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
|
||||
.lp-field input,
|
||||
.lp-field textarea {
|
||||
width: 100%; padding: 9px 12px; box-sizing: border-box;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
font-size: 0.9rem; font-family: inherit;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.lp-field textarea { resize: vertical; }
|
||||
.lp-message { margin-top: 10px; }
|
||||
.lp-field input:focus,
|
||||
.lp-field textarea:focus { outline: none; border-color: var(--primary); }
|
||||
.lp-choice { display: flex; gap: 10px; }
|
||||
.lp-choice button {
|
||||
flex: 1; padding: 8px 12px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 0.85rem; background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.lp-choice button:hover { border-color: var(--primary); }
|
||||
.lp-choice button[aria-pressed='true'] {
|
||||
background: var(--primary); color: var(--primary-fg);
|
||||
border-color: var(--primary); font-weight: 700;
|
||||
}
|
||||
.lp-hint { margin-top: 6px; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.lp-error { padding: 8px 12px; border-radius: 6px; background: var(--wrong-bg); color: var(--wrong-fg); font-size: 0.85rem; }
|
||||
.lp-sent { text-align: center; padding: 32px 0; }
|
||||
.lp-sent h3 { margin: 0 0 8px; font-size: 1.2rem; }
|
||||
.lp-sent p { margin: 0; color: var(--text-muted); font-size: 0.9rem; }
|
||||
.lp-sent-mark { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.lp-card-lg {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--card-radius, 12px); padding: 30px;
|
||||
}
|
||||
|
||||
/* ── Auth modal ───────────────────────────────────────────────────── */
|
||||
|
||||
.lp-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 1000; padding: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.55);
|
||||
animation: lp-fade 0.18s ease both;
|
||||
}
|
||||
.lp-modal {
|
||||
position: relative; width: 100%; max-width: 400px; padding: 28px 24px;
|
||||
background: var(--card-bg); border-radius: var(--card-radius, 14px);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
animation: lp-rise 0.22s ease both;
|
||||
}
|
||||
@keyframes lp-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes lp-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
.lp-modal-close {
|
||||
position: absolute; top: 12px; right: 14px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text-muted); font-size: 1.2rem;
|
||||
}
|
||||
.lp-tabs { display: flex; margin-bottom: 20px; border-bottom: 2px solid var(--border); }
|
||||
.lp-tabs button {
|
||||
flex: 1; padding: 10px 0; margin-bottom: -2px;
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; font-size: 0.95rem; color: var(--text-muted);
|
||||
}
|
||||
.lp-tabs button[aria-pressed='true'] { font-weight: 700; color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.lp-modal-note { text-align: center; padding: 12px 0; }
|
||||
.lp-modal-note h3 { margin: 0 0 8px; }
|
||||
.lp-modal-note p { margin: 0 0 16px; font-size: 0.875rem; color: var(--text-muted); }
|
||||
.lp-modal-mark { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.lp-unverified {
|
||||
margin-bottom: 14px; padding: 12px 14px; border-radius: 8px;
|
||||
background: var(--expl-bg); border: 1px solid var(--expl-bd);
|
||||
}
|
||||
.lp-unverified p { margin: 0 0 8px; font-size: 0.85rem; font-weight: 500; color: var(--text); }
|
||||
.lp-modal .btn-block { width: 100%; }
|
||||
.lp-modal .alert { margin-bottom: 14px; }
|
||||
.lp-forgot { margin-top: 12px; text-align: center; font-size: 0.82rem; }
|
||||
.lp-forgot a { color: var(--text-muted); }
|
||||
.lp-mismatch { color: var(--wrong-fg); font-size: 0.8rem; }
|
||||
.lp-invite { text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────────── */
|
||||
|
||||
.lp-footer { background: var(--navbar-bg); color: var(--navbar-fg); padding: 30px 24px; }
|
||||
.lp-footer-inner {
|
||||
max-width: 1080px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: wrap; gap: 16px;
|
||||
}
|
||||
.lp-footer-brand { font-weight: 700; font-size: 0.9rem; opacity: 0.62; }
|
||||
.lp-footer-brand span { font-weight: 400; margin-left: 14px; }
|
||||
.lp-footer-links { display: flex; gap: 22px; font-size: 0.85rem; }
|
||||
.lp-footer-links a,
|
||||
.lp-footer-links button {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font: inherit; text-decoration: none;
|
||||
color: rgba(255,255,255,0.5); transition: color 0.15s ease;
|
||||
}
|
||||
.lp-footer-links a:hover,
|
||||
.lp-footer-links button:hover { color: rgba(255,255,255,0.85); }
|
||||
|
||||
/* ── Scroll reveal ────────────────────────────────────────────────── */
|
||||
|
||||
/* Applied by the observer in the page component. The starting state lives
|
||||
here rather than in a style attribute so that the reduced-motion block
|
||||
below can cancel it — a hidden element with no animation to un-hide it is
|
||||
how "respecting the setting" turns into a blank page. */
|
||||
.lp-reveal { opacity: 0; transform: translateY(18px); transition: opacity 0.55s ease, transform 0.55s ease; }
|
||||
.lp-reveal.is-in { opacity: 1; transform: none; }
|
||||
|
||||
/* A card both arrives on scroll and lifts under the cursor, which means one
|
||||
element with two reasons to transition and one `transition` property to say
|
||||
so in. Spelled out together here, rather than letting whichever rule comes
|
||||
last silently discard the other's. */
|
||||
.lp-reveal.lp-card,
|
||||
.lp-reveal.lp-claim {
|
||||
transition: opacity 0.5s ease, transform 0.35s ease,
|
||||
box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
/* Read by a screen reader, never seen. The rotating line is decoration for
|
||||
anyone who can watch it change; this is the same four rules as one
|
||||
sentence, for anyone who cannot. */
|
||||
.lp-sr {
|
||||
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
||||
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Reduced motion ───────────────────────────────────────────────── */
|
||||
|
||||
/* Everything above that moves, stopped — and the two states that motion was
|
||||
meant to travel between resolved to the visible one. The rotating line
|
||||
stops cycling in the component itself, because a timer is not a CSS
|
||||
animation and this block cannot reach it. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lp-page *,
|
||||
.lp-page *::before,
|
||||
.lp-page *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
.lp-reveal { opacity: 1 !important; transform: none !important; }
|
||||
.lp-hero h1 em { animation: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lp-section { padding: 60px 20px; }
|
||||
.lp-hero { padding: 72px 20px 64px; }
|
||||
.lp-cta .btn { width: 100%; }
|
||||
}
|
||||
|
|
@ -1,49 +1,290 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import useMediaQuery from '../hooks/useMediaQuery'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Captcha, { captchaSiteKey } from '../components/Captcha'
|
||||
import './LandingPage.css'
|
||||
|
||||
/**
|
||||
* The page a stranger lands on.
|
||||
*
|
||||
* It describes the product as it is now: a question bank you sit as sessions,
|
||||
* an analysis that says where you stand, and an assistant that is only allowed
|
||||
* to answer out of your own library. The previous copy described the one
|
||||
* feature the site launched with — extracting questions out of a PDF — which
|
||||
* had stopped being the point some months ago.
|
||||
*
|
||||
* Nothing here is decorative motion for its own sake. The line under the
|
||||
* heading cycles because it has four things to say and room for one; the
|
||||
* figures count because they are real and worth looking at; the cards arrive
|
||||
* on scroll because a page this long otherwise lands all at once. Every one of
|
||||
* those stops dead under `prefers-reduced-motion`, and nothing on the page
|
||||
* needs movement to be read.
|
||||
*/
|
||||
|
||||
// The four rules of the adaptive picker, in the order it applies them. Kept
|
||||
// deliberately close to the wording in docs/adaptive-sessions.md: a landing
|
||||
// page that promises something subtly different from what the algorithm does
|
||||
// is a landing page that will be wrong the first time anybody checks.
|
||||
const ADAPTIVE_STEPS = [
|
||||
'what you have never seen',
|
||||
'then your weakest topic',
|
||||
'then what you got wrong, oldest first',
|
||||
'all of it weighted by the real paper',
|
||||
]
|
||||
|
||||
const FIGURES = [
|
||||
['questions', 'Questions'],
|
||||
['topics', 'Topics'],
|
||||
['systems', 'Systems'],
|
||||
['articles', 'Articles'],
|
||||
['exams', 'Exams'],
|
||||
]
|
||||
|
||||
// ── Feature cards data ────────────────────────────────────────────────────────
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: '📄',
|
||||
title: 'Quiz from Any PDF',
|
||||
desc: 'Upload board review material, textbook chapters, or lecture slides. AI extracts questions with answers and explanations — no formatting required.',
|
||||
icon: 'timer',
|
||||
title: 'A player built for how the paper asks',
|
||||
desc: 'Sit a session as study or as an exam block. Rule an option out and it stays out, highlight the part of the stem that matters, open an attending tip when you are stuck.',
|
||||
points: [
|
||||
'Per-question timing that only runs while the screen is actually open',
|
||||
'A warning before the block ends, not after',
|
||||
'Study mode marks as you go; exam mode waits until the end',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🎓',
|
||||
title: 'AI Tutor Built In',
|
||||
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
|
||||
icon: 'target',
|
||||
title: 'Sessions that decide themselves',
|
||||
desc: 'Ask for twenty questions and the bank works out which twenty. Never-seen first, then the topic you are weakest in, then the ones you got wrong, oldest first.',
|
||||
points: [
|
||||
'Scaled by the share of the real exam each topic is published as carrying',
|
||||
'A topic the blueprint does not cover takes the median weight, never zero',
|
||||
'It picks what to study, not what the paper is made of',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🩺',
|
||||
title: 'Clinical Assistant',
|
||||
desc: 'Access pediatric workflows, bedside calculators, dosing support, and clinical references alongside your study tools.',
|
||||
icon: 'chart',
|
||||
title: 'An analysis that says where you stand',
|
||||
desc: 'Readiness, score over time, how much of the bank you have covered, and a split of correct, correct after a tip, and incorrect — because those are three different things.',
|
||||
points: [
|
||||
'A knowledge profile across Articles, Systems and Disciplines',
|
||||
'Relevance taken from the ABP content outline, not from our own proportions',
|
||||
'Every session reviewable question by question, long after it is closed',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔊',
|
||||
title: 'Audio Mode',
|
||||
desc: 'Every question read aloud with natural-sounding voices. Study during rounds, commuting, or on a run — no screen needed.',
|
||||
icon: 'spark',
|
||||
title: 'AI Mode, and what it is not allowed to do',
|
||||
desc: 'Ask it anything and it answers out of your library alone. Retrieval builds the shortlist, the shortlist is the whole prompt, and any citation retrieval did not find is deleted before you see the answer.',
|
||||
points: [
|
||||
'Every claim arrives with a source you can open and check',
|
||||
'Turn any answer straight into a practice session on the same material',
|
||||
'No library, no answer — it says so rather than inventing one',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🏦',
|
||||
title: 'Question Bank',
|
||||
desc: 'All questions in one searchable place. Filter by category, topic, or search semantically — "hyponatremia in neonates" just works.',
|
||||
icon: 'book',
|
||||
title: 'Articles that read like a reference',
|
||||
desc: 'Written material with cross-references between them and tips set inline in the prose, so the explanation is where the question left you rather than three clicks away.',
|
||||
points: ['Flashcards', 'Courses with modules and lessons', 'Study plans you can hold yourself to'],
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Track Your Progress',
|
||||
desc: 'Every attempt saved. Review past scores, see where you struggled, and focus your study time where it counts.',
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Fast by Design',
|
||||
desc: 'Study mode loads instantly. The AI works in the background — it never holds up the quiz. No spinners on the critical path.',
|
||||
icon: 'shield',
|
||||
title: 'A sign-up that reports you to nobody',
|
||||
desc: 'The challenge in front of registration is self-hosted. It runs here, on this server, and no third party is told that you visited, tried to sign up, or how long you thought about it.',
|
||||
},
|
||||
]
|
||||
|
||||
const CLAIMS = [
|
||||
{
|
||||
title: 'Where the model writes',
|
||||
body: 'AI Mode, and the drafting of explanations and tips. In AI Mode its sources are fixed before it starts and checked after it stops — a model cannot cite something it was not given.',
|
||||
},
|
||||
{
|
||||
title: 'Where it does not',
|
||||
body: 'Choosing your next session is arithmetic on your own answers and a published exam blueprint. It is inspectable, it is the same every time, and no model is asked to guess at it.',
|
||||
},
|
||||
{
|
||||
title: 'Why the difference matters',
|
||||
body: 'A tool that says "powered by AI" and stops there is asking to be believed. The point of every constraint above is that you can check the claim instead.',
|
||||
},
|
||||
]
|
||||
|
||||
const CLINICAL_TILES = [
|
||||
['Well visits', '2wk → 18yr'],
|
||||
['Milestones', '2mo → 5yr'],
|
||||
['Vaccines', 'Full schedule'],
|
||||
['Dosing', 'Weight-based'],
|
||||
['References', 'Plans + pathways'],
|
||||
['Bedside', 'Emergency care'],
|
||||
]
|
||||
|
||||
const ICONS = {
|
||||
timer: <><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M9 1h6" /></>,
|
||||
target: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="0.6" /></>,
|
||||
chart: <><path d="M3 21h18M7 17V9M12 17V4M17 17v-6" /></>,
|
||||
spark: <><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" /><path d="M18 16.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" /></>,
|
||||
book: <><path d="M4 4.5A1.5 1.5 0 015.5 3H19v18H5.5A1.5 1.5 0 014 19.5z" /><path d="M8 7h7M8 11h7" /></>,
|
||||
shield: <><path d="M12 3l7 3v6c0 4.2-2.9 7.9-7 9-4.1-1.1-7-4.8-7-9V6z" /><path d="M9 12l2 2 4-4" /></>,
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{ICONS[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** True when the visitor has asked their system for less movement. */
|
||||
function useCalmMotion() {
|
||||
return useMediaQuery('(prefers-reduced-motion: reduce)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `is-in` the first time the element is scrolled into view.
|
||||
*
|
||||
* Where there is no IntersectionObserver — an older browser, or jsdom under
|
||||
* test — the element is revealed immediately rather than never. Content that
|
||||
* depends on an optional API to become visible is content that disappears.
|
||||
*/
|
||||
function useReveal() {
|
||||
const ref = useRef(null)
|
||||
const [shown, setShown] = useState(typeof IntersectionObserver === 'undefined')
|
||||
|
||||
useEffect(() => {
|
||||
if (shown || !ref.current) return undefined
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) setShown(true)
|
||||
}, { rootMargin: '0px 0px -60px 0px' })
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [shown])
|
||||
|
||||
return [ref, shown ? 'lp-reveal is-in' : 'lp-reveal']
|
||||
}
|
||||
|
||||
function Reveal({ children, className = '' }) {
|
||||
const [ref, revealClass] = useReveal()
|
||||
return <div ref={ref} className={`${revealClass} ${className}`.trim()}>{children}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts from nothing up to the figure it was given.
|
||||
*
|
||||
* The count is animation, so the number the page reports is the target from
|
||||
* the first render — an assistive reader is given the finished figure, and
|
||||
* anyone who has asked for calm motion simply sees it.
|
||||
*/
|
||||
function useCountUp(target, animate) {
|
||||
const [shown, setShown] = useState(animate ? 0 : target)
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate || typeof requestAnimationFrame !== 'function') { setShown(target); return undefined }
|
||||
const started = Date.now()
|
||||
let frame = requestAnimationFrame(function step() {
|
||||
const progress = Math.min(1, (Date.now() - started) / 1100)
|
||||
// Eased out, so it arrives at the figure rather than stopping at it.
|
||||
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))))
|
||||
if (progress < 1) frame = requestAnimationFrame(step)
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [target, animate])
|
||||
|
||||
return shown
|
||||
}
|
||||
|
||||
function Figure({ value, label, animate }) {
|
||||
const shown = useCountUp(value, animate)
|
||||
// The name is on the item and the digits inside it are hidden, so a screen
|
||||
// reader hears "2,924 questions" once instead of a number ticking upwards.
|
||||
return (
|
||||
<li className="lp-figure" aria-label={`${value.toLocaleString()} ${label.toLowerCase()}`}>
|
||||
<span className="lp-figure-value" aria-hidden="true">{shown.toLocaleString()}</span>
|
||||
<span className="lp-figure-label" aria-hidden="true">{label}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much there is, asked of the thing that knows.
|
||||
*
|
||||
* When the count cannot be fetched the section keeps its heading and loses its
|
||||
* figures. Rendering zeros instead would be a claim, and a false one: the bank
|
||||
* is not empty, we just could not reach it.
|
||||
*/
|
||||
function PublicFigures() {
|
||||
const calm = useCalmMotion()
|
||||
const [stats, setStats] = useState(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
api.get('/public/stats')
|
||||
.then(res => { if (live) setStats(res.data || null) })
|
||||
.catch(() => { if (live) setFailed(true) })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
|
||||
const figures = FIGURES
|
||||
.map(([key, label]) => [label, Number(stats?.[key])])
|
||||
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
||||
|
||||
return (
|
||||
<section className="lp-section lp-section-raised">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-head">
|
||||
<h2>What is in the bank today</h2>
|
||||
<p>Counted at the moment you loaded this page, not typed into it last spring.</p>
|
||||
</div>
|
||||
<Reveal>
|
||||
{figures.length > 0 && (
|
||||
<ul className="lp-figures" aria-label="What is in the bank today">
|
||||
{figures.map(([label, value]) => (
|
||||
<Figure key={label} value={value} label={label} animate={!calm} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{failed && (
|
||||
<p className="lp-figures-missing">
|
||||
The counts could not be reached just now. Rather than show you a number we
|
||||
have not checked, here is none.
|
||||
</p>
|
||||
)}
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One line, four things to say.
|
||||
*
|
||||
* Under calm motion it settles on the first and stays there; the whole
|
||||
* sentence is in the document either way, for anyone reading it rather than
|
||||
* watching it.
|
||||
*/
|
||||
function Rotator({ calm }) {
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (calm) return undefined
|
||||
const timer = setInterval(() => setIndex(i => (i + 1) % ADAPTIVE_STEPS.length), 2600)
|
||||
return () => clearInterval(timer)
|
||||
}, [calm])
|
||||
|
||||
return (
|
||||
<p className="lp-rotator">
|
||||
<span className="lp-rotator-label">Your next session:</span>
|
||||
<span className="lp-sr">{ADAPTIVE_STEPS.join(', ')}.</span>
|
||||
<span className="lp-rotator-word" key={index} aria-hidden="true">{ADAPTIVE_STEPS[index]}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Contact form ──────────────────────────────────────────────────────────────
|
||||
function ContactForm() {
|
||||
const [form, setForm] = useState({ name: '', email: '', type: 'question', message: '' })
|
||||
|
|
@ -69,10 +310,10 @@ function ContactForm() {
|
|||
}
|
||||
|
||||
if (sent) return (
|
||||
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>✓</div>
|
||||
<h3 style={{ fontSize: '1.2rem', marginBottom: 8 }}>Message received</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
<div className="lp-sent">
|
||||
<div className="lp-sent-mark" aria-hidden="true">✓</div>
|
||||
<h3>Message received</h3>
|
||||
<p>
|
||||
{form.type === 'moderator'
|
||||
? "We'll review your application and get back to you."
|
||||
: "We'll get back to you shortly."}
|
||||
|
|
@ -81,65 +322,44 @@ function ContactForm() {
|
|||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 200px), 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Name</label>
|
||||
<input
|
||||
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Your name" required maxLength={120}
|
||||
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<form onSubmit={submit} className="lp-form">
|
||||
<div className="lp-form-row">
|
||||
<div className="lp-field">
|
||||
<label htmlFor="lp-contact-name">Name</label>
|
||||
<input id="lp-contact-name" value={form.name} required maxLength={120}
|
||||
placeholder="Your name"
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Email</label>
|
||||
<input
|
||||
type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="you@example.com" required
|
||||
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div className="lp-field">
|
||||
<label htmlFor="lp-contact-email">Email</label>
|
||||
<input id="lp-contact-email" type="email" value={form.email} required
|
||||
placeholder="you@example.com"
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div className="lp-field">
|
||||
<label htmlFor="lp-contact-message">Message</label>
|
||||
<div className="lp-choice">
|
||||
{[['question', '💬 Question'], ['moderator', '🛡 Apply as Moderator']].map(([val, label]) => (
|
||||
<button
|
||||
key={val} type="button"
|
||||
onClick={() => setForm(f => ({ ...f, type: val }))}
|
||||
style={{
|
||||
flex: 1, padding: '8px 12px', borderRadius: 8, fontSize: '0.85rem', cursor: 'pointer',
|
||||
fontWeight: form.type === val ? 700 : 400,
|
||||
background: form.type === val ? 'var(--primary)' : 'var(--bg)',
|
||||
color: form.type === val ? 'var(--primary-fg)' : 'var(--text)',
|
||||
border: `1px solid ${form.type === val ? 'var(--primary)' : 'var(--border)'}`,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>{label}</button>
|
||||
<button key={val} type="button" aria-pressed={form.type === val}
|
||||
onClick={() => setForm(f => ({ ...f, type: val }))}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{form.type === 'moderator' && (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 6 }}>
|
||||
Moderators can upload PDFs and manage quiz content. Tell us about your background and how you'd like to contribute.
|
||||
<p className="lp-hint">
|
||||
Moderators can upload material and manage the question bank. Tell us about your
|
||||
background and how you would like to contribute.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Message</label>
|
||||
<textarea
|
||||
value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))}
|
||||
placeholder={form.type === 'moderator' ? "Tell us about your background, specialty, and how you'd like to help..." : 'Your question or message...'}
|
||||
required rows={4} maxLength={2000}
|
||||
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<textarea id="lp-contact-message" className="lp-message" value={form.message} required rows={4} maxLength={2000}
|
||||
placeholder={form.type === 'moderator'
|
||||
? 'Tell us about your background, specialty, and how you would like to help…'
|
||||
: 'Your question or message…'}
|
||||
onChange={e => setForm(f => ({ ...f, message: e.target.value }))} />
|
||||
</div>
|
||||
<Captcha onVerify={setCaptchaToken} />
|
||||
{error && <div style={{ color: 'var(--wrong-fg)', fontSize: '0.85rem', background: 'var(--wrong-bg)', padding: '8px 12px', borderRadius: 6 }}>{error}</div>}
|
||||
<button
|
||||
type="submit" disabled={loading || !canSubmit}
|
||||
className="btn btn-primary"
|
||||
style={{ alignSelf: 'flex-start', minWidth: 140 }}
|
||||
>
|
||||
{error && <div className="lp-error">{error}</div>}
|
||||
<button type="submit" disabled={loading || !canSubmit} className="btn btn-primary">
|
||||
{loading ? 'Sending…' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
|
|
@ -230,39 +450,23 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
const isLogin = mode === 'login'
|
||||
|
||||
return (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
||||
}}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
background: 'var(--card-bg)', borderRadius: 'var(--card-radius, 14px)',
|
||||
padding: '28px 24px', maxWidth: 400, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', position: 'relative',
|
||||
}}>
|
||||
<button onClick={onClose} style={{
|
||||
position: 'absolute', top: 12, right: 14, background: 'none',
|
||||
border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem',
|
||||
}}>✕</button>
|
||||
<div onClick={onClose} className="lp-modal-backdrop">
|
||||
<div onClick={e => e.stopPropagation()} className="lp-modal">
|
||||
<button onClick={onClose} className="lp-modal-close" aria-label="Close">✕</button>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: '2px solid var(--border)' }}>
|
||||
<div className="lp-tabs">
|
||||
{[['login', 'Sign In'], ['register', 'Register']].map(([m, label]) => (
|
||||
<button key={m} onClick={() => switchMode(m)} style={{
|
||||
flex: 1, padding: '10px 0', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontWeight: mode === m ? 700 : 400, fontSize: '0.95rem',
|
||||
color: mode === m ? 'var(--primary)' : 'var(--text-muted)',
|
||||
borderBottom: mode === m ? '2px solid var(--primary)' : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
}}>{label}</button>
|
||||
<button key={m} onClick={() => switchMode(m)} aria-pressed={mode === m}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Registration success */}
|
||||
{registered && (
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📧</div>
|
||||
<h3 style={{ marginBottom: 8 }}>Check your email</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
|
||||
<div className="lp-modal-note">
|
||||
<div className="lp-modal-mark" aria-hidden="true">📧</div>
|
||||
<h3>Check your email</h3>
|
||||
<p>
|
||||
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
|
||||
|
|
@ -275,17 +479,15 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
{isLogin && !registered && (
|
||||
<>
|
||||
{unverified && !resendSent && (
|
||||
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: '12px 14px', marginBottom: 14 }}>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '0.85rem', color: '#92400e', fontWeight: 500 }}>
|
||||
Email not verified. Check your inbox.
|
||||
</p>
|
||||
<div className="lp-unverified">
|
||||
<p>Email not verified. Check your inbox.</p>
|
||||
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
|
||||
{resending ? 'Sending…' : 'Resend verification email'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{resendSent && <div className="alert alert-success" style={{ marginBottom: 14 }}>Verification email sent.</div>}
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
|
||||
{resendSent && <div className="alert alert-success">Verification email sent.</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form aria-label="Sign in" onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="modal-login-email">Email</label>
|
||||
|
|
@ -295,12 +497,12 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
<label htmlFor="modal-login-password">Password</label>
|
||||
<input id="modal-login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
<button className="btn btn-primary btn-block" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
<div style={{ textAlign: 'center', marginTop: 12, fontSize: '0.82rem' }}>
|
||||
<Link to="/forgot-password" onClick={onClose} style={{ color: 'var(--text-muted)' }}>Forgot password?</Link>
|
||||
<div className="lp-forgot">
|
||||
<Link to="/forgot-password" onClick={onClose}>Forgot password?</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -308,7 +510,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
{/* Register form */}
|
||||
{!isLogin && !registered && (
|
||||
<>
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleRegister}>
|
||||
{/* Labelled by id: these two were bare <label>s that neither
|
||||
wrapped their input nor named it, so a screen reader met two
|
||||
|
|
@ -333,9 +535,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
<input id="modal-reg-confirm" type="password" value={confirm} required minLength={8}
|
||||
autoComplete="new-password" onChange={e => setConfirm(e.target.value)} />
|
||||
{confirm && password !== confirm && (
|
||||
<small style={{ color: 'var(--wrong-fg)', fontSize: '0.8rem' }}>
|
||||
These do not match yet.
|
||||
</small>
|
||||
<small className="lp-mismatch">These do not match yet.</small>
|
||||
)}
|
||||
</div>
|
||||
{inviteRequired && (
|
||||
|
|
@ -345,16 +545,13 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
whether a code is valid before the account is made —
|
||||
that would be a place to guess them. */}
|
||||
<input id="modal-invite-code" value={inviteCode} required autoComplete="off"
|
||||
placeholder="From whoever invited you"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||
placeholder="From whoever invited you" className="lp-invite"
|
||||
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
|
||||
<small style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
|
||||
This site is invite only.
|
||||
</small>
|
||||
<small className="lp-hint">This site is invite only.</small>
|
||||
</div>
|
||||
)}
|
||||
<Captcha onVerify={setCaptchaToken} />
|
||||
<button className="btn btn-primary" style={{ width: '100%' }}
|
||||
<button className="btn btn-primary btn-block"
|
||||
disabled={loading || !password || password !== confirm
|
||||
|| (captchaSiteKey() && !captchaToken)
|
||||
|| (inviteRequired && !inviteCode.trim())}>
|
||||
|
|
@ -372,12 +569,12 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
|
||||
export default function LandingPage() {
|
||||
const { user } = useAuth()
|
||||
const calm = useCalmMotion()
|
||||
const [authModal, setAuthModal] = useState(null) // null | 'login' | 'register'
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--bg)' }}>
|
||||
<div className="lp-page">
|
||||
|
||||
{/* Auth modal overlay */}
|
||||
{authModal && (
|
||||
<AuthModal
|
||||
mode={authModal}
|
||||
|
|
@ -386,179 +583,130 @@ export default function LandingPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* ── Shared Navbar (handles auth state) ─────────────────────────────── */}
|
||||
<style>{`.navbar { margin-bottom: 0 !important; }`}</style>
|
||||
<Navbar onSignIn={() => setAuthModal('login')} onRegister={() => setAuthModal('register')} />
|
||||
|
||||
{/* ── Hero ───────────────────────────────────────────────────────────── */}
|
||||
<section style={{
|
||||
background: 'var(--navbar-bg)',
|
||||
color: 'var(--navbar-fg)',
|
||||
padding: '96px 24px 88px',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* subtle grid */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, opacity: 0.04,
|
||||
backgroundImage: 'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
<div style={{ position: 'relative', maxWidth: 720, margin: '0 auto' }}>
|
||||
<div style={{ display: 'inline-block', background: 'rgba(96,165,250,0.15)', border: '1px solid rgba(96,165,250,0.3)', borderRadius: 20, padding: '4px 14px', fontSize: '0.78rem', fontWeight: 600, color: '#93c5fd', marginBottom: 28, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
Pediatric Learning Platform
|
||||
</div>
|
||||
<h1 style={{ fontSize: 'clamp(2.2rem, 5vw, 3.4rem)', fontWeight: 800, lineHeight: 1.15, margin: '0 0 22px', letterSpacing: '-0.03em' }}>
|
||||
Turn your textbooks<br />
|
||||
<span style={{ background: 'linear-gradient(90deg, #60a5fa, #a78bfa)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
into tests
|
||||
</span>
|
||||
</h1>
|
||||
<p style={{ fontSize: '1.15rem', color: 'rgba(226,232,240,0.75)', lineHeight: 1.7, marginBottom: 40, maxWidth: 560, margin: '0 auto 40px' }}>
|
||||
Upload any PDF — board review material, lecture notes, textbook chapters.
|
||||
AI extracts questions, reads them aloud, and explains every answer.
|
||||
<section className="lp-hero">
|
||||
<div className="lp-hero-inner">
|
||||
<span className="lp-eyebrow">Pediatric board preparation</span>
|
||||
<h1>Sit the paper<br /><em>before you sit the paper.</em></h1>
|
||||
<p className="lp-lead">
|
||||
A pediatric question bank you take as timed blocks or as study, an analysis
|
||||
that tells you where you actually stand, and an assistant that is only
|
||||
permitted to answer out of your own library.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Rotator calm={calm} />
|
||||
<div className="lp-cta">
|
||||
{user ? <>
|
||||
<Link to="/" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Dashboard</Link>
|
||||
<Link to="/sessions" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>My Quizzes</Link>
|
||||
<Link to="/question-bank" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>Question Bank</Link>
|
||||
<Link to="/" className="btn btn-primary">Dashboard</Link>
|
||||
<Link to="/study/new" className="btn lp-cta-ghost">Start a session</Link>
|
||||
<Link to="/sessions" className="btn lp-cta-ghost">Your analysis</Link>
|
||||
</> : <>
|
||||
<button onClick={() => setAuthModal('login')} className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Sign In</button>
|
||||
<button onClick={() => setAuthModal('register')} className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)' }}>Register</button>
|
||||
<button onClick={() => setAuthModal('register')} className="btn btn-primary">Create an account</button>
|
||||
<button onClick={() => setAuthModal('login')} className="btn lp-cta-ghost">Sign in</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ───────────────────────────────────────────────────────── */}
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '80px 24px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 56 }}>
|
||||
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2.2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
|
||||
Everything you need to study smarter
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '1.05rem', maxWidth: 480, margin: '0 auto' }}>
|
||||
Built specifically for pediatric board prep and clinical learning.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))', gap: 20 }}>
|
||||
{FEATURES.map((f, i) => (
|
||||
<div key={i} className="card" style={{ padding: '24px', transition: 'transform 0.2s, box-shadow 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 32px rgba(0,0,0,0.12)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.boxShadow = '' }}
|
||||
>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 12 }}>{f.icon}</div>
|
||||
<h3 style={{ fontWeight: 700, fontSize: '1rem', margin: '0 0 8px' }}>{f.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', lineHeight: 1.65, margin: 0 }}>{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
<PublicFigures />
|
||||
|
||||
{/* ── What it does ───────────────────────────────────────────────────── */}
|
||||
<section className="lp-section">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-head">
|
||||
<h2>What you get</h2>
|
||||
<p>Six things, each of which is doing something specific about a real problem with revising.</p>
|
||||
</div>
|
||||
<div className="lp-grid">
|
||||
{FEATURES.map(feature => (
|
||||
<Reveal key={feature.title} className="lp-card">
|
||||
<span className="lp-card-mark"><Icon name={feature.icon} /></span>
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.desc}</p>
|
||||
{feature.points && (
|
||||
<ul>{feature.points.map(point => <li key={point}>{point}</li>)}</ul>
|
||||
)}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Clinical tools section ─────────────────────────────────────────── */}
|
||||
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ display: 'inline-block', background: 'rgba(96,165,250,0.15)', border: '1px solid rgba(96,165,250,0.3)', borderRadius: 20, padding: '4px 14px', fontSize: '0.78rem', fontWeight: 600, color: '#93c5fd', marginBottom: 20, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
Also from PedsHub
|
||||
</div>
|
||||
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
|
||||
Pediatric Clinical Tools
|
||||
</h2>
|
||||
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
|
||||
A pediatric clinical assistant for daily practice: well visit workflows,
|
||||
developmental milestones, vaccine schedules, catch-up planning,
|
||||
bedside calculators, dosing support, emergency pathways, and concise
|
||||
clinical references in one place, powered by Peds-AI clinical assistant tools.
|
||||
{/* ── Where the AI is ────────────────────────────────────────────────── */}
|
||||
<section className="lp-section lp-section-dark">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-head">
|
||||
<span className="lp-eyebrow">Yes, it is AI</span>
|
||||
<h2>And here is exactly where</h2>
|
||||
<p>
|
||||
The interesting part of a claim is what would show it to be wrong. So: this
|
||||
is which parts of the site a language model touches, and which parts it is
|
||||
kept out of.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}>
|
||||
{[
|
||||
{ text: '🩺 Well visit planner from newborn through adolescence' },
|
||||
{ text: '📋 Developmental milestone tracking across 4 domains' },
|
||||
{ text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' },
|
||||
{ text: '💊 Weight-based dosing and pediatric calculators' },
|
||||
{ text: '🤖 Peds-AI clinical assistant for quick pediatric guidance' },
|
||||
{ text: '🚨 Emergency pathways for sepsis, status epilepticus, RSI, burns, and anaphylaxis', badge: 'NEW' },
|
||||
{ text: '🎙 Optional voice-to-note support for structured documentation' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}>
|
||||
<span>{item.text}</span>
|
||||
{item.badge && (
|
||||
<span style={{ background: 'rgba(34,197,94,0.18)', color: '#86efac', border: '1px solid rgba(34,197,94,0.35)', borderRadius: 10, padding: '1px 7px', fontSize: '0.64rem', fontWeight: 700, letterSpacing: '0.05em', flexShrink: 0 }}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href="https://app.pedshub.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
|
||||
>
|
||||
Open Clinical Tools ↗
|
||||
</div>
|
||||
<div className="lp-claims">
|
||||
{CLAIMS.map(claim => (
|
||||
<Reveal key={claim.title} className="lp-claim">
|
||||
<h3>{claim.title}</h3>
|
||||
<p>{claim.body}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Clinical tools ─────────────────────────────────────────────────── */}
|
||||
<section className="lp-section lp-section-dark">
|
||||
<div className="lp-inner lp-split">
|
||||
<div>
|
||||
<span className="lp-eyebrow">Also from PedsHub</span>
|
||||
<h2>Pediatric clinical tools</h2>
|
||||
<p>
|
||||
The same material, pointed at the ward instead of the exam: well visit
|
||||
planning from newborn to adolescence, developmental milestones, the full
|
||||
AAP/ACIP schedule with a catch-up planner, weight-based dosing, and
|
||||
emergency pathways for sepsis, status epilepticus, RSI, burns and
|
||||
anaphylaxis.
|
||||
</p>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer"
|
||||
className="btn btn-primary">
|
||||
Open clinical tools ↗
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}>
|
||||
{[
|
||||
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
|
||||
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
|
||||
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
|
||||
{ icon: '💊', label: 'Dosing', sub: 'Weight-based' },
|
||||
{ icon: '📋', label: 'Clinical Assistant', sub: 'References + plans' },
|
||||
{ icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 10, padding: '14px',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.09)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
|
||||
>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: 6 }}>{item.icon}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.85rem', color: '#e2e8f0' }}>{item.label}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{item.sub}</div>
|
||||
<div className="lp-tiles">
|
||||
{CLINICAL_TILES.map(([label, sub]) => (
|
||||
<div key={label} className="lp-tile">
|
||||
<div className="lp-tile-label">{label}</div>
|
||||
<div className="lp-tile-sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Contact form ───────────────────────────────────────────────────── */}
|
||||
<section id="contact" style={{ maxWidth: 680, margin: '0 auto', padding: '80px 24px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 40 }}>
|
||||
<h2 style={{ fontSize: 'clamp(1.5rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
|
||||
Get in touch
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>
|
||||
Have a question? Want to contribute as a moderator? We'd love to hear from you.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '32px' }}>
|
||||
<ContactForm />
|
||||
{/* ── Contact ────────────────────────────────────────────────────────── */}
|
||||
<section id="contact" className="lp-section lp-section-raised">
|
||||
<div className="lp-inner-narrow">
|
||||
<div className="lp-head">
|
||||
<h2>Get in touch</h2>
|
||||
<p>A question, or an offer to help write and review the bank. Both are welcome.</p>
|
||||
</div>
|
||||
<div className="lp-card-lg">
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ─────────────────────────────────────────────────────────── */}
|
||||
<footer style={{
|
||||
background: 'var(--navbar-bg)',
|
||||
padding: '32px 24px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<span style={{ fontWeight: 700, color: 'rgba(226,232,240,0.6)', fontSize: '0.9rem' }}>
|
||||
🏥 PedsHub
|
||||
<span style={{ fontWeight: 400, marginLeft: 16 }}>© {new Date().getFullYear()}</span>
|
||||
<footer className="lp-footer">
|
||||
<div className="lp-footer-inner">
|
||||
<span className="lp-footer-brand">
|
||||
🏥 PedsHub<span>© {new Date().getFullYear()}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
||||
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
||||
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Clinical Tools</a>
|
||||
<div className="lp-footer-links">
|
||||
<button onClick={() => setAuthModal('login')}>Sign In</button>
|
||||
<a href="#contact">Contact</a>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
149
frontend/src/pages/LandingPage.test.jsx
Normal file
149
frontend/src/pages/LandingPage.test.jsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
// Stands in for the real bar only to expose the two callbacks the page hands
|
||||
// it; what the navbar itself renders is its own tests' business.
|
||||
vi.mock('../components/Navbar', () => ({
|
||||
default: ({ onSignIn, onRegister }) => (
|
||||
<nav>
|
||||
<button onClick={onSignIn}>Open login</button>
|
||||
<button onClick={onRegister}>Open registration</button>
|
||||
</nav>
|
||||
),
|
||||
}))
|
||||
|
||||
import LandingPage from './LandingPage'
|
||||
import { AuthProvider } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
// What /api/public/stats returned the day this was written. The point of the
|
||||
// endpoint is that these move, so nothing here asserts a particular size —
|
||||
// only that whatever came back is what appears.
|
||||
const STATS = { questions: 2924, topics: 673, systems: 15, articles: 8, exams: 2 }
|
||||
|
||||
const realMatchMedia = window.matchMedia
|
||||
|
||||
/** Answers `reduce` for the motion query and no for everything else. */
|
||||
function stubReducedMotion() {
|
||||
window.matchMedia = vi.fn(query => ({
|
||||
matches: query.includes('prefers-reduced-motion'),
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
function mount() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider><LandingPage /></AuthProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
api.get.mockImplementation(url => Promise.resolve({
|
||||
data: url === '/public/stats' ? STATS : {},
|
||||
}))
|
||||
api.post.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = realMatchMedia
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('the figures', () => {
|
||||
it('reports the counts the endpoint gave it', async () => {
|
||||
mount()
|
||||
expect(api.get).toHaveBeenCalledWith('/public/stats')
|
||||
// Named on the item rather than read off the digits, which are still
|
||||
// counting upwards at this point and are hidden from assistive readers
|
||||
// for exactly that reason.
|
||||
const figures = within(await screen.findByRole('list', { name: /in the bank today/i }))
|
||||
expect(figures.getByRole('listitem', { name: '2,924 questions' })).toBeInTheDocument()
|
||||
expect(figures.getByRole('listitem', { name: '673 topics' })).toBeInTheDocument()
|
||||
expect(figures.getByRole('listitem', { name: '15 systems' })).toBeInTheDocument()
|
||||
expect(figures.getByRole('listitem', { name: '8 articles' })).toBeInTheDocument()
|
||||
expect(figures.getByRole('listitem', { name: '2 exams' })).toBeInTheDocument()
|
||||
// And the digits do arrive at the real number rather than stopping
|
||||
// somewhere short of it. Longer than the default wait, because the count
|
||||
// itself takes about a second.
|
||||
await waitFor(() => expect(screen.getByText('2,924')).toBeInTheDocument(), { timeout: 4000 })
|
||||
})
|
||||
|
||||
it('shows nothing at all rather than a nought when the count cannot be fetched', async () => {
|
||||
api.get.mockImplementation(url => (url === '/public/stats'
|
||||
? Promise.reject(new Error('unreachable'))
|
||||
: Promise.resolve({ data: {} })))
|
||||
mount()
|
||||
expect(await screen.findByText(/counts could not be reached/i)).toBeInTheDocument()
|
||||
// A nought here would be a claim that the bank is empty, which it is not.
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: /in the bank today/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits a figure the endpoint did not send instead of inventing one', async () => {
|
||||
api.get.mockImplementation(url => Promise.resolve({
|
||||
data: url === '/public/stats' ? { questions: 2924 } : {},
|
||||
}))
|
||||
mount()
|
||||
const figures = within(await screen.findByRole('list', { name: /in the bank today/i }))
|
||||
expect(figures.getByRole('listitem', { name: '2,924 questions' })).toBeInTheDocument()
|
||||
expect(figures.getAllByRole('listitem')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reduced motion', () => {
|
||||
it('lands on the finished figures and stops the line cycling', async () => {
|
||||
stubReducedMotion()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mount()
|
||||
// No count: the number is simply there, first paint.
|
||||
expect(await screen.findByText('2,924')).toBeInTheDocument()
|
||||
|
||||
const first = 'what you have never seen'
|
||||
expect(screen.getByText(first)).toBeInTheDocument()
|
||||
// Three full turns of the carousel it would otherwise have run.
|
||||
await act(async () => { vi.advanceTimersByTime(9000) })
|
||||
expect(screen.getByText(first)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still puts the whole of the rotating line in the document to be read', async () => {
|
||||
stubReducedMotion()
|
||||
mount()
|
||||
// The four rules as one sentence, for anyone who is reading the page
|
||||
// rather than watching it.
|
||||
expect(await screen.findByText(/what you have never seen, then your weakest topic/i))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the auth modal', () => {
|
||||
it('still opens from the navbar and switches between its two tabs', async () => {
|
||||
mount()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
|
||||
expect(screen.getByRole('form', { name: 'Sign in' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Register' }))
|
||||
expect(screen.getByLabelText('Confirm password')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Confirm password')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens straight onto registration from the navbar', async () => {
|
||||
mount()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open registration' }))
|
||||
expect(screen.getByLabelText('Confirm password')).toBeInTheDocument()
|
||||
expect(api.get).toHaveBeenCalledWith('/auth/signup-policy')
|
||||
})
|
||||
})
|
||||
|
|
@ -482,6 +482,9 @@ export default function QuizPage() {
|
|||
const [note, setNote] = useState('')
|
||||
const [noteSaved, setNoteSaved] = useState(true)
|
||||
const [folders, setFolders] = useState([])
|
||||
// Searching the folders you have and naming one you do not, in the same box.
|
||||
const [folderQuery, setFolderQuery] = useState('')
|
||||
const [folderBusy, setFolderBusy] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [responseStats, setResponseStats] = useState(null)
|
||||
const [statsError, setStatsError] = useState('')
|
||||
|
|
@ -1056,6 +1059,26 @@ const timerStarted = timeLeft !== null
|
|||
? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f)))
|
||||
} catch { /* the row stays unticked, which is the honest signal */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the folder and put the question in it, in one press.
|
||||
*
|
||||
* The panel used to say "make one in the question bank" and leave you to go
|
||||
* and do it — which means leaving the question you were reading, and coming
|
||||
* back to find your place. Naming a folder is the whole of making one.
|
||||
*/
|
||||
const createFolderWith = async (title, questionId) => {
|
||||
const name = title.trim()
|
||||
if (!name) return
|
||||
setFolderBusy(true)
|
||||
try {
|
||||
const made = await api.post('/collections/', { title: name })
|
||||
await api.put(`/collections/${made.data.id}/questions/${questionId}`)
|
||||
setFolders(list => [...list, { ...made.data, question_ids: [questionId] }])
|
||||
setFolderQuery('')
|
||||
} catch { /* nothing is added, and the field keeps what was typed */ }
|
||||
finally { setFolderBusy(false) }
|
||||
}
|
||||
/**
|
||||
* Choosing is answering.
|
||||
*
|
||||
|
|
@ -1850,28 +1873,64 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'save' && (
|
||||
<div className="quiz-panel">
|
||||
<p className="quiz-panel-title">Save to a folder</p>
|
||||
{folders.length === 0 ? (
|
||||
<p className="quiz-panel-empty">No folders yet — make one in the question bank.</p>
|
||||
) : (
|
||||
<ul className="quiz-folders">
|
||||
{folders.map(folder => {
|
||||
const inIt = (folder.question_ids || []).includes(current.id)
|
||||
return (
|
||||
<li key={folder.id}>
|
||||
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
|
||||
onClick={() => saveToFolder(folder.id, current.id)}>
|
||||
{inIt ? '✓ ' : '+ '}{folder.title}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{panel === 'save' && (() => {
|
||||
const needle = folderQuery.trim().toLowerCase()
|
||||
const found = folders.filter(f => f.title.toLowerCase().includes(needle))
|
||||
// Offered only when nothing you already have is called this —
|
||||
// two folders of the same name is a filing system nobody can use.
|
||||
const canCreate = needle
|
||||
&& !folders.some(f => f.title.trim().toLowerCase() === needle)
|
||||
return (
|
||||
<div className="quiz-panel">
|
||||
<p className="quiz-panel-title">Save question in folder</p>
|
||||
{/* One box for both. Searching what you have and naming
|
||||
what you do not are the same act — you type the name of
|
||||
the folder you want and it either exists or it doesn't. */}
|
||||
<input className="quiz-folder-find" value={folderQuery}
|
||||
placeholder="Create or find folder" aria-label="Create or find folder"
|
||||
onChange={e => setFolderQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && canCreate) {
|
||||
e.preventDefault()
|
||||
createFolderWith(folderQuery, current.id)
|
||||
}
|
||||
}} />
|
||||
|
||||
{canCreate && (
|
||||
<>
|
||||
<p className="quiz-folder-heading">Create folder</p>
|
||||
<button type="button" className="quiz-folder-new" disabled={folderBusy}
|
||||
onClick={() => createFolderWith(folderQuery, current.id)}>
|
||||
<span>{folderQuery.trim()}</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{found.length > 0 && (
|
||||
<ul className="quiz-folders">
|
||||
{found.map(folder => {
|
||||
const inIt = (folder.question_ids || []).includes(current.id)
|
||||
return (
|
||||
<li key={folder.id}>
|
||||
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
|
||||
onClick={() => saveToFolder(folder.id, current.id)}>
|
||||
{inIt ? '✓ ' : '+ '}{folder.title}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!found.length && !canCreate && (
|
||||
<p className="quiz-panel-empty">
|
||||
{folders.length ? 'No folder by that name.' : 'Type a name to make your first folder.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Figures are rows now. The legacy single path is still shown
|
||||
for anything that was never backfilled. */}
|
||||
|
|
|
|||
|
|
@ -728,6 +728,61 @@ describe('quiz player', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('finds a folder and makes one from the same box', async () => {
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/collections/') return Promise.resolve({ data: [{ id: 4, title: 'Cardiology misses', question_ids: [] }] })
|
||||
if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: {
|
||||
id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1,
|
||||
time_limit_minutes: timeLimit, attempt_mode: url.includes('attempt_id=') ? mode : null,
|
||||
questions,
|
||||
} })
|
||||
if (url === '/attempts/progress') return Promise.resolve({ data: null })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
api.put.mockResolvedValue({ data: {} })
|
||||
await begin()
|
||||
// It folds away behind the ⋯ control, with the other occasional actions.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'More options' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save to a folder/ }))
|
||||
|
||||
const box = screen.getByLabelText('Create or find folder')
|
||||
await userEvent.type(box, 'cardio')
|
||||
// What you already have, found by the same typing — and still the offer to
|
||||
// make one called exactly that, because wanting a narrower folder named
|
||||
// "cardio" is not the same as wanting the one called "Cardiology misses".
|
||||
expect(screen.getByRole('button', { name: /Cardiology misses/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Create folder')).toBeInTheDocument()
|
||||
|
||||
await userEvent.clear(box)
|
||||
await userEvent.type(box, 'Neonatology')
|
||||
// Nothing by that name, so the same box offers to make it.
|
||||
expect(screen.getByText('Create folder')).toBeInTheDocument()
|
||||
api.post.mockResolvedValue({ data: { id: 9, title: 'Neonatology' } })
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Neonatology/ }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/collections/', { title: 'Neonatology' }))
|
||||
// Made and filled in one press, without leaving the question.
|
||||
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/collections/9/questions/1'))
|
||||
})
|
||||
|
||||
it('will not offer to make a folder that already exists', async () => {
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/collections/') return Promise.resolve({ data: [{ id: 4, title: 'Saved', question_ids: [] }] })
|
||||
if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: {
|
||||
id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1,
|
||||
time_limit_minutes: timeLimit, attempt_mode: url.includes('attempt_id=') ? mode : null,
|
||||
questions,
|
||||
} })
|
||||
if (url === '/attempts/progress') return Promise.resolve({ data: null })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
await begin()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'More options' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save to a folder/ }))
|
||||
await userEvent.type(screen.getByLabelText('Create or find folder'), 'saved')
|
||||
// Two folders of the same name is a filing system nobody can use.
|
||||
expect(screen.queryByText('Create folder')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts timed quizzes in exam mode without a mode prompt', async () => {
|
||||
quizModeVar = 'timed'
|
||||
await begin(false)
|
||||
|
|
|
|||
|
|
@ -497,3 +497,25 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
|
||||
/* Two buttons in a dialog sit side by side, the one that acts first. */
|
||||
.quiz-away-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
||||
|
||||
/* ── Saving a question into a folder ──────────────────────────────────
|
||||
One box for finding and naming: you type the folder you want, and it
|
||||
either exists already or the panel offers to make it. */
|
||||
.quiz-folder-find {
|
||||
width: 100%; padding: 9px 12px; margin-bottom: 10px;
|
||||
font: inherit; font-size: 0.88rem;
|
||||
border: 1px solid var(--border); border-radius: 9px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
.quiz-folder-heading {
|
||||
margin: 0 0 6px; font-size: 0.68rem; font-weight: 700;
|
||||
letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.quiz-folder-new {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
width: 100%; padding: 10px 12px; margin-bottom: 10px;
|
||||
font: inherit; font-size: 0.88rem; font-weight: 600; cursor: pointer;
|
||||
background: var(--correct-bg); color: var(--correct-fg);
|
||||
border: 1px solid var(--correct-bd); border-radius: 9px;
|
||||
}
|
||||
.quiz-folder-new:disabled { opacity: 0.6; cursor: default; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue