From a5021a324100ec18fc75a865fe280dc71c6df942 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Apr 2026 01:12:19 +0200 Subject: [PATCH] UI overhaul, bug fixes, section delete, category at extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes / CSS: - Warm Brown theme: parchment cream background, dark brown navbar, Georgia serif font beautiful and easy on eyes for reading medical content - Default theme: slightly warmer bg, softer card shadows, 1200px container (less boxy) - Navbar: sticky, height-fixed, overflow-x scroll on mobile (no wrapping), smaller text on mobile - Mobile container: 14px padding for more space Bug fixes: - Login page: axios 401 interceptor no longer triggers window.location.href for auth endpoints (was causing immediate page reload before error message was visible) - Dashboard quizzes stat now shows distinct quizzes attempted (not created by user) - PostgreSQL email collation: ALTER users.email to COLLATE "C" in migration (prevents B-tree index corruption with en_US.utf8 locale causing user lookups to fail) - get_current_user: verifies email on ALL protected endpoints, returns 403 with X-Unverified header Frontend auto-logs out and redirects to /login where resend option is shown DocumentDetailPage: - Delete Section button with confirmation on each section row - Create question category inline from dropdown (+ New button → prompt) - Question Bank Category dropdown now shows question count Dashboard: - total_quizzes stat counts distinct attempted quizzes (not user-created quizzes) Backend: - DELETE /documents/{id}/sections/{section_id} endpoint added Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/routers/attempts.py | 6 +- backend/app/routers/documents.py | 15 +++ frontend/src/api/client.js | 6 +- frontend/src/index.css | 129 +++++++++++----------- frontend/src/pages/DocumentDetailPage.jsx | 74 ++++++++++--- frontend/src/pages/SettingsPage.jsx | 2 +- 6 files changed, 149 insertions(+), 83 deletions(-) diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 8100269..d3beb99 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -195,7 +195,11 @@ def get_dashboard_stats( current_user: User = Depends(get_current_user), ): total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count() - total_quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).count() + # Count distinct quizzes the user has attempted (not created) + total_quizzes = db.query(QuizAttempt.quiz_id).filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None), + ).distinct().count() completed_attempts = db.query(QuizAttempt).filter( QuizAttempt.user_id == current_user.id, diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index 8dd2494..fffeef9 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -156,6 +156,21 @@ def create_section( return section +@router.delete("/{document_id}/sections/{section_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_section( + document_id: int, + section_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + doc = _get_doc_or_404(document_id, current_user, db) + section = db.query(Section).filter(Section.id == section_id, Section.document_id == document_id).first() + if not section: + raise HTTPException(status_code=404, detail="Section not found") + db.delete(section) + db.commit() + + @router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_document( document_id: int, diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 2e5178a..f733a45 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -15,7 +15,11 @@ api.interceptors.request.use((config) => { api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) { + const url = error.config?.url || '' + // Don't redirect to login from the login/register/verify endpoints themselves + const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || + url.includes('/auth/verify') || url.includes('/auth/reset') || url.includes('/auth/forgot') + if (error.response?.status === 401 && !isAuthEndpoint) { localStorage.removeItem('token') window.location.href = '/login' } diff --git a/frontend/src/index.css b/frontend/src/index.css index fac504e..e149321 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,10 +1,10 @@ /* ── CSS Variables (themes) ─────────────────────────────────── */ :root { - --bg: #f1f5f9; + --bg: #f0f4f8; --card-bg: #ffffff; - --card-border: 1px solid transparent; - --card-shadow: 0 1px 4px rgba(0,0,0,0.08); - --card-radius: 12px; + --card-border: 1px solid rgba(0,0,0,0.05); + --card-shadow: 0 1px 6px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.03); + --card-radius: 14px; --primary: #2563eb; --primary-hover: #1d4ed8; --primary-fg: #ffffff; @@ -30,67 +30,62 @@ --badge-radius: 12px; } +/* ── Warm Brown / Parchment theme ────────────────────────────── */ [data-theme="markdown"] { - --bg: #eaeef2; - --card-bg: #ffffff; - --card-border: 1px solid #d0d7de; - --card-shadow: none; - --card-radius: 6px; - --primary: #0969da; - --primary-hover: #0757ba; - --primary-fg: #ffffff; - --text: #1f2328; - --text-muted: #57606a; - --text-subtle: #8c959f; - --border: #d0d7de; - --input-bg: #f6f8fa; - --option-bg: #ffffff; - --option-hover: #f6f8fa; - --option-sel-bg: #ddf4ff; - --option-sel-bd: #0969da; - --correct-bg: #dafbe1; - --correct-bd: #82cfb5; - --correct-fg: #116329; - --wrong-bg: #ffebe9; - --wrong-bd: #ff9d8a; - --wrong-fg: #82071e; - --expl-bg: #f6f8fa; - --expl-bd: #0969da; - --navbar-bg: #24292f; - --navbar-fg: #e6edf3; - --badge-radius: 2em; - --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; - --font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --bg: #f2ebe0; + --card-bg: #faf6ef; + --card-border: 1px solid #d6c9b6; + --card-shadow: 0 1px 3px rgba(80,50,20,0.07); + --card-radius: 8px; + --primary: #7c4a1e; + --primary-hover: #633c18; + --primary-fg: #fdf8f0; + --text: #2c1a0e; + --text-muted: #7a5c42; + --text-subtle: #a8886a; + --border: #d6c9b6; + --input-bg: #fdf8f0; + --option-bg: #fdf8f0; + --option-hover: #f5ede0; + --option-sel-bg: #f5e6d3; + --option-sel-bd: #7c4a1e; + --correct-bg: #e6f4e6; + --correct-bd: #8cbf8c; + --correct-fg: #1a4d1a; + --wrong-bg: #fdecea; + --wrong-bd: #d9908a; + --wrong-fg: #6b1e1e; + --expl-bg: #f8f3ea; + --expl-bd: #b8945a; + --navbar-bg: #2c1a0e; + --navbar-fg: #f2e8d8; + --badge-radius: 4px; + --font-body: Georgia, 'Times New Roman', 'Palatino', serif; + --font-heading: Georgia, 'Times New Roman', serif; } -/* ── Markdown theme typography overrides ───────────────────── */ +/* ── Warm Brown theme overrides ─────────────────────────────── */ body[data-theme="markdown"] { - font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); + font-family: var(--font-body); font-size: 16px; + line-height: 1.75; } -[data-theme="markdown"] .card h2, +[data-theme="markdown"] .navbar .logo { color: #d4a96a; } [data-theme="markdown"] h1, [data-theme="markdown"] h2, [data-theme="markdown"] h3 { font-family: var(--font-heading); - font-weight: 600; - border-bottom: 1px solid var(--border); - padding-bottom: 8px; - margin-bottom: 16px; + font-weight: 700; + color: var(--text); } -[data-theme="markdown"] .question-card h3 { border-bottom: none; padding-bottom: 0; font-size: 1.1rem; } +[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; } [data-theme="markdown"] .question-card { border-left: 4px solid var(--primary); } -[data-theme="markdown"] .explanation { - font-family: var(--font-body); line-height: 1.8; - border: 1px solid var(--border); border-left: 4px solid var(--expl-bd); - background: var(--expl-bg); -} -[data-theme="markdown"] .option { border-radius: 6px; font-size: 0.95rem; } -[data-theme="markdown"] .option-letter { border-radius: 3px; font-family: monospace; } -[data-theme="markdown"] .card { border-radius: 6px; } -[data-theme="markdown"] .btn { font-size: 0.875rem; } -[data-theme="markdown"] .review-card { border-radius: 6px; } -[data-theme="markdown"] .score-display .score-value { letter-spacing: -3px; } +[data-theme="markdown"] .question-card h3 { font-family: var(--font-heading); font-size: 1.05rem; line-height: 1.65; } +[data-theme="markdown"] .option { border-radius: 6px; font-size: 0.95rem; font-family: inherit; } +[data-theme="markdown"] .option-letter { border-radius: 3px; font-family: Georgia, serif; font-style: italic; } +[data-theme="markdown"] .explanation { font-family: inherit; line-height: 1.85; border-left: 3px solid var(--expl-bd); background: var(--expl-bg); } +[data-theme="markdown"] .btn { font-family: inherit; } +[data-theme="markdown"] .score-display .score-value { letter-spacing: -2px; font-family: Georgia, serif; } /* ── Reset ──────────────────────────────────────────────────── */ * { margin: 0; padding: 0; box-sizing: border-box; } @@ -105,17 +100,18 @@ body { } /* ── Layout ─────────────────────────────────────────────────── */ -.container { max-width: 1100px; margin: 0 auto; padding: 0 20px; } +.container { max-width: 1200px; margin: 0 auto; padding: 0 28px; } /* ── Navbar ─────────────────────────────────────────────────── */ -.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 12px 0; margin-bottom: 28px; } -.navbar .container { display: flex; justify-content: space-between; align-items: center; } -.navbar .logo { font-size: 1.2rem; font-weight: 700; color: #60a5fa; text-decoration: none; } -[data-theme="minimal"] .navbar .logo { color: #a1a1aa; } -.navbar nav { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; } -.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.88rem; opacity: 0.75; } -.navbar a:hover { opacity: 1; } -.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.18); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; } +.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 0 0 0; margin-bottom: 32px; position: sticky; top: 0; z-index: 50; } +.navbar .container { display: flex; justify-content: space-between; align-items: center; height: 52px; } +.navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; } +[data-theme="markdown"] .navbar .logo { color: #d4a96a; } +.navbar nav { display: flex; gap: 4px; align-items: center; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; } +.navbar nav::-webkit-scrollbar { display: none; } +.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.83rem; opacity: 0.7; padding: 6px 10px; border-radius: 6px; white-space: nowrap; transition: opacity 0.15s, background 0.15s; } +.navbar a:hover { opacity: 1; background: rgba(255,255,255,0.08); } +.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; white-space: nowrap; } /* ── Cards ──────────────────────────────────────────────────── */ .card { @@ -302,10 +298,15 @@ body { /* ── Responsive ─────────────────────────────────────────────── */ @media (max-width: 768px) { + .container { padding: 0 14px; } .grid-2 { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr 1fr; } - .question-card, .review-card { padding: 20px 16px; } - .card { padding: 20px; } + .question-card, .review-card { padding: 18px 14px; } + .card { padding: 18px 16px; } .quiz-nav { flex-wrap: wrap; } .quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; } + .navbar .container { height: 48px; } + .navbar .logo { font-size: 1rem; } + .navbar a { font-size: 0.78rem; padding: 5px 7px; } + .navbar button { padding: 4px 10px; font-size: 0.78rem; } } diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index 518fa08..b916ef1 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -11,6 +11,7 @@ export default function DocumentDetailPage() { const [loading, setLoading] = useState(true) const [sectionForm, setSectionForm] = useState({ name: '', start_page: 1, end_page: 10 }) const [creating, setCreating] = useState(false) + const [deletingSection, setDeletingSection] = useState(null) const [generating, setGenerating] = useState(null) const [quizTitle, setQuizTitle] = useState('') const [quizMode, setQuizMode] = useState('timed') @@ -91,6 +92,28 @@ export default function DocumentDetailPage() { } } + const deleteSection = async (sectionId) => { + if (!confirm('Delete this section? Associated quizzes will also be deleted.')) return + setDeletingSection(sectionId) + try { + await api.delete(`/documents/${id}/sections/${sectionId}`) + fetchDoc() + } catch (err) { + setError(err.response?.data?.detail || 'Failed to delete section') + } finally { setDeletingSection(null) } + } + + const createCategoryAndSelect = async (name) => { + if (!name.trim()) return + try { + const res = await api.post('/question-categories/', { name: name.trim() }) + setQuestionCategories(prev => [...prev, res.data]) + setSelectedQuestionCategoryId(String(res.data.id)) + } catch (err) { + setError(err.response?.data?.detail || 'Failed to create category') + } + } + const deleteDoc = async () => { if (!confirm('Delete this document and all its quizzes?')) return await api.delete(`/documents/${id}`) @@ -216,14 +239,23 @@ export default function DocumentDetailPage() { )}
- +
+ + +

- Extracted questions will be added to the Question Bank and tagged with this category. + Extracted questions will be tagged with this category in the Question Bank.

@@ -240,15 +272,25 @@ export default function DocumentDetailPage() {
{isModerator && ( - + <> + + + )}
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 0c7ee13..c211445 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -85,7 +85,7 @@ function AppearanceSection() { const { theme, setTheme } = useTheme() const themes = [ { value: 'default', label: 'Default', desc: 'Clean blue and white', icon: '🎨' }, - { value: 'markdown', label: 'Markdown', desc: 'GitHub-inspired monochrome', icon: '📝' }, + { value: 'markdown', label: 'Warm Brown', desc: 'Parchment serif — easy on the eyes', icon: '📖' }, ] return (