diff --git a/docs/TODO.md b/docs/TODO.md index 512dc49..a464dbe 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -233,6 +233,16 @@ Captured so nothing is lost while the article writing runs. ## Loose ends +- [ ] **Image thumbnails and caching** — you mentioned a tool from the ped-ai + work that generates thumbnails and caches through Caddy so images load + fast, click to open full size, same bucket, and no straightforward + download of the original. Not started: I need the name of that tool or a + pointer to it before wiring anything, and "make originals hard to pull" + is a decision about how far to go (a signed short-lived URL per view is + the honest version; watermarking and right-click blocking are not). + MediaAsset already stores one path per image, so a `thumb_path` beside it + plus a Caddy cache rule is the shape. + - [ ] **QuestionEditModal is dead code** — nothing has imported it since Edit moved to the full page. Its sibling CreateQuestionModal was removed when its last two callers were replaced; this one was already unreferenced, so diff --git a/frontend/src/components/ExamSwitcher.css b/frontend/src/components/ExamSwitcher.css new file mode 100644 index 0000000..a1db30d --- /dev/null +++ b/frontend/src/components/ExamSwitcher.css @@ -0,0 +1,88 @@ +/* The current study objective: a button that says what it is, and a dialog + that lets you change it. */ + +.exam-switcher-button { + display: inline-flex; align-items: baseline; gap: 6px; flex-shrink: 0; + max-width: 280px; padding: 5px 11px; + font: inherit; font-size: 0.8rem; cursor: pointer; + background: var(--input-bg); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; +} +.exam-switcher-button:hover { border-color: var(--primary); } +.exam-switcher-label { + font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); white-space: nowrap; +} +.exam-switcher-name { + font-weight: 650; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +@media (max-width: 900px) { .exam-switcher-label { display: none; } } + +/* ── The dialog ───────────────────────────────────────────────────── */ +.exo-overlay { + position: fixed; inset: 0; z-index: 1100; display: flex; + align-items: center; justify-content: center; padding: 16px; + background: rgba(15, 23, 42, 0.5); +} +.exo { + display: flex; flex-direction: column; + width: min(920px, 100%); max-height: min(86vh, 780px); + background: var(--card-bg); border-radius: 14px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25); +} +.exo-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 24px 0; } +.exo-head h2 { margin: 0; font-size: 1.25rem; font-weight: 700; } +.exo-head button { + width: 34px; height: 34px; border: 0; border-radius: 50%; + background: none; cursor: pointer; color: var(--text-muted); font-size: 1.05rem; +} +.exo-head button:hover { background: var(--bg); color: var(--text); } +.exo-lead { margin: 6px 24px 0; font-size: 0.86rem; line-height: 1.6; color: var(--text-muted); max-width: 68ch; } + +.exo-search { + margin: 14px 24px 0; padding: 9px 12px; + /* 16px on touch so iOS does not zoom the page in on focus. */ + font-size: 16px; font-family: inherit; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +@media (min-width: 700px) { .exo-search { font-size: 0.88rem; } } + +.exo-body { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 24px 4px; } +.exo-family { + margin: 18px 0 8px; padding: 8px 12px; + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; + color: var(--text-muted); background: var(--bg); border-radius: 8px; +} +.exo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; } + +.exo-option { + display: flex; align-items: flex-start; gap: 11px; padding: 13px 15px; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; + cursor: pointer; +} +.exo-option:hover { border-color: var(--primary); } +.exo-option.is-on { border-color: var(--primary); background: var(--option-sel-bg); } +.exo-option input { width: 18px; height: 18px; margin-top: 1px; flex-shrink: 0; } +.exo-option strong { display: block; font-size: 0.92rem; font-weight: 650; } +.exo-option small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); } +.exo-option.is-all { margin-top: 8px; } +/* Shown, so an educator can see it exists; not selectable, because choosing it + would scope the bank to nothing. */ +.exo-option.is-empty { opacity: 0.55; cursor: not-allowed; } +.exo-option.is-empty small { color: var(--wrong-fg); } + +.exo-empty { padding: 24px 0; font-size: 0.88rem; color: var(--text-muted); } +.exo-error { margin: 0 24px; font-size: 0.85rem; color: var(--wrong-fg); } +.exo-foot { + display: flex; justify-content: flex-end; gap: 10px; + padding: 16px 24px calc(20px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); +} + +@media (max-width: 600px) { + .exo { max-height: 100%; height: 100%; border-radius: 0; } + .exo-overlay { padding: 0; } + .exo-grid { grid-template-columns: 1fr; } +} diff --git a/frontend/src/components/ExamSwitcher.jsx b/frontend/src/components/ExamSwitcher.jsx index 5d655dd..0892d84 100644 --- a/frontend/src/components/ExamSwitcher.jsx +++ b/frontend/src/components/ExamSwitcher.jsx @@ -1,44 +1,144 @@ -import { useState, useEffect } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import api from '../api/client' +import './ExamSwitcher.css' /** - * Which exam the learner is studying for. The choice is stored on the user, so - * it follows them between devices rather than living in this browser. + * What the learner is revising for. + * + * It was a ` choose(e.target.value)}> - - {exams.map(exam => ( - - ))} - - + <> + + + {open && ( +
e.target === e.currentTarget && setOpen(false)}> +
+
+

Current study objective

+ +
+

+ Scopes the question bank, the filters and your performance analysis. + Material linked to no exam stays visible whichever you pick. +

+ + setQuery(e.target.value)} /> + +
+ + + {families.map(([family, list]) => ( +
+

{family}

+
+ {list.map(exam => { + // An objective with nothing behind it would empty the + // bank. Shown, so an educator can see it exists, but not + // selectable until it has questions. + const empty = exam.question_count === 0 + return ( + + ) + })} +
+
+ ))} + {families.length === 0 &&

Nothing matches that.

} +
+ + {error &&

{error}

} +
+ + +
+
+
+ )} + ) } diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 2d39dbf..7a6b561 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link, useLocation } from 'react-router-dom' import ScrollStrip from './ScrollStrip' import { useAuth } from '../context/AuthContext' @@ -61,6 +61,56 @@ function JobsBadge({ jobs }) { ) } +/** + * You, rather than the material: dashboard, account, settings, sign out. + * + * These were four more entries in the section bar, which is what made it + * outrun its width. They belong together and none of them is a place you go to + * study, so they sit behind the one control that is about the person. + */ +function AccountMenu({ user, onLogout }) { + const [open, setOpen] = useState(false) + const wrap = useRef(null) + + useEffect(() => { + if (!open) return undefined + const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) } + const onKey = e => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', away) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', away) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const initial = (user?.name || user?.email || '?').trim().charAt(0).toUpperCase() + + return ( +
+ + {open && ( +
+
+ {user?.name} + {user?.email} +
+ setOpen(false)}>Dashboard + setOpen(false)}>Account + setOpen(false)}>Settings + +
+ )} +
+ ) +} + + export default function Navbar({ onSignIn, onRegister }) { const { user, logout } = useAuth() const [menuOpen, setMenuOpen] = useState(false) @@ -104,9 +154,11 @@ export default function Navbar({ onSignIn, onRegister }) { }, [user]) + // Where you go to study. Home is the signed-out landing page, so it is not + // one of these; Dashboard, Account and Settings are about you rather than + // about the material, and live under the account menu — which is what keeps + // this row short enough not to need scrolling in the first place. const navLinks = user ? [ - { to: '/home', label: 'Home' }, - { to: '/', label: 'Dashboard' }, { to: '/ai', label: 'AI Mode' }, // One entry. Sessions and analysis are the same subject — the list of what // you have sat and the reading of how it went — so they are one page. @@ -119,7 +171,6 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/articles', label: 'Reading' }, { to: '/flashcards', label: 'Cards' }, { to: '/courses', label: 'Courses' }, - { to: '/settings', label: '⚙ Settings' }, ] : [] return ( @@ -134,7 +185,7 @@ export default function Navbar({ onSignIn, onRegister }) { {user ? (
- +