UI overhaul, bug fixes, section delete, category at extraction
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) <noreply@anthropic.com>
This commit is contained in:
parent
f2d15c3c57
commit
a5021a3241
6 changed files with 149 additions and 83 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
<div className="form-group">
|
||||
<label>Add Questions to Bank Category <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<select value={selectedQuestionCategoryId} onChange={e => setSelectedQuestionCategoryId(e.target.value)}>
|
||||
<option value="">— No category —</option>
|
||||
{questionCategories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name} ({c.question_count} questions)</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={selectedQuestionCategoryId} onChange={e => setSelectedQuestionCategoryId(e.target.value)} style={{ flex: 1 }}>
|
||||
<option value="">— No category —</option>
|
||||
{questionCategories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}{c.question_count > 0 ? ` (${c.question_count})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-secondary btn-sm" style={{ whiteSpace: 'nowrap' }}
|
||||
onClick={() => {
|
||||
const name = prompt('New category name:')
|
||||
if (name) createCategoryAndSelect(name)
|
||||
}}>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -240,15 +272,25 @@ export default function DocumentDetailPage() {
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{isModerator && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => generateQuiz(section.id, section.name)}
|
||||
disabled={generating === section.id}
|
||||
>
|
||||
{generating === section.id ? (
|
||||
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
|
||||
) : 'Extract & Create Quiz'}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => generateQuiz(section.id, section.name)}
|
||||
disabled={generating === section.id}
|
||||
>
|
||||
{generating === section.id ? (
|
||||
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
|
||||
) : 'Extract & Create Quiz'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => deleteSection(section.id)}
|
||||
disabled={deletingSection === section.id}
|
||||
title="Delete this section"
|
||||
>
|
||||
{deletingSection === section.id ? '…' : 'Delete'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Section title="Appearance">
|
||||
|
|
|
|||
Loading…
Reference in a new issue