Add persistent MyNote for quizzes
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s

This commit is contained in:
Daniel 2026-05-12 17:51:23 +02:00
parent 4b75edef4c
commit 9f97218f39
12 changed files with 376 additions and 3 deletions

View file

@ -46,7 +46,7 @@ For detailed architecture documentation, see [docs/architecture.md](docs/archite
## Quick Start
```bash
git clone https://github.com/ifedan-ed/pdf-quiz-generator.git
git clone ssh://git.danvics.com:2222/danvics/pdf-quiz-generator.git
cd pdf-quiz-generator
# Configure environment

View file

@ -21,7 +21,7 @@ if _db_url:
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule # noqa
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule, UserNote # noqa
from app.database import Base
target_metadata = Base.metadata

View file

@ -0,0 +1,38 @@
"""add user notes
Revision ID: e4c7b2a9d6f1
Revises: 9bac7bf02e38
Create Date: 2026-05-12 17:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'e4c7b2a9d6f1'
down_revision: Union[str, None] = '9bac7bf02e38'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS user_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"""))
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_user_notes_id ON user_notes (id)"))
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_user_notes_user_id ON user_notes (user_id)"))
def downgrade() -> None:
op.drop_index(op.f('ix_user_notes_user_id'), table_name='user_notes')
op.drop_index(op.f('ix_user_notes_id'), table_name='user_notes')
op.drop_table('user_notes')

View file

@ -11,7 +11,7 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -607,6 +607,7 @@ app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
@app.get("/api/health")

View file

@ -7,6 +7,7 @@ from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite
from app.models.user_note import UserNote
__all__ = [
"User",
@ -19,4 +20,5 @@ __all__ = [
"ReminderSchedule",
"AIModelConfig",
"Favorite",
"UserNote",
]

View file

@ -24,6 +24,7 @@ class User(Base):
attempts = relationship("QuizAttempt", back_populates="user")
reminders = relationship("ReminderSchedule", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False)
@property
def is_admin(self):

View file

@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import relationship
from app.database import Base
class UserNote(Base):
__tablename__ = "user_notes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
content = Column(Text, nullable=False, default="")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", back_populates="note")

View file

@ -0,0 +1,50 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.models.user_note import UserNote
from app.utils.auth import get_current_user
router = APIRouter()
class MyNoteResponse(BaseModel):
content: str
updated_at: datetime | None = None
class MyNoteUpdate(BaseModel):
content: str = Field(default="", max_length=50000)
@router.get("", response_model=MyNoteResponse)
def get_my_note(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = db.query(UserNote).filter(UserNote.user_id == current_user.id).first()
if not note:
return {"content": "", "updated_at": None}
return {"content": note.content or "", "updated_at": note.updated_at}
@router.put("", response_model=MyNoteResponse)
def save_my_note(
data: MyNoteUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = db.query(UserNote).filter(UserNote.user_id == current_user.id).first()
if not note:
note = UserNote(user_id=current_user.id, content=data.content)
db.add(note)
else:
note.content = data.content
note.updated_at = datetime.utcnow()
db.commit()
db.refresh(note)
return {"content": note.content or "", "updated_at": note.updated_at}

View file

@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from 'react'
import api from '../api/client'
function formatSavedAt(value) {
if (!value) return 'Not saved yet'
return `Saved ${new Date(value).toLocaleString()}`
}
export default function MyNote({ variant = 'tab' }) {
const [open, setOpen] = useState(false)
const [content, setContent] = useState('')
const [updatedAt, setUpdatedAt] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const saveRef = useRef(null)
const loadedRef = useRef(false)
const loadNote = async () => {
if (loadedRef.current) return
setLoading(true)
setError('')
try {
const res = await api.get('/mynote')
setContent(res.data.content || '')
setUpdatedAt(res.data.updated_at || null)
loadedRef.current = true
} catch {
setError('Could not load MyNote')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (variant === 'card') loadNote()
}, [variant])
useEffect(() => {
if (!loadedRef.current) return
clearTimeout(saveRef.current)
saveRef.current = setTimeout(async () => {
setSaving(true)
setError('')
try {
const res = await api.put('/mynote', { content })
setUpdatedAt(res.data.updated_at || null)
} catch {
setError('Could not save MyNote')
} finally {
setSaving(false)
}
}, 700)
return () => clearTimeout(saveRef.current)
}, [content])
const openNote = () => {
setOpen(true)
loadNote()
}
const preview = content.trim()
? content.trim().slice(0, 150) + (content.trim().length > 150 ? '...' : '')
: 'Your single study note will appear here once you start writing during quizzes.'
const editor = (
<div className="mynote-editor">
<div className="mynote-editor-header">
<div>
<div className="mynote-title">MyNote</div>
<div className="mynote-status">{saving ? 'Saving...' : formatSavedAt(updatedAt)}</div>
</div>
{variant === 'tab' && <button className="mynote-close" type="button" onClick={() => setOpen(false)}>Collapse</button>}
</div>
{error && <div className="mynote-error">{error}</div>}
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder={loading ? 'Loading MyNote...' : 'Capture one running note across all quizzes...'}
disabled={loading}
/>
</div>
)
if (variant === 'card') {
return (
<div className="card mynote-card">
<div className="mynote-card-copy">
<div className="mynote-eyebrow">Saved across all quiz sessions</div>
<h2>MyNote</h2>
<p>{preview}</p>
<span>{formatSavedAt(updatedAt)}</span>
</div>
<button className="btn btn-primary" type="button" onClick={openNote}>Open MyNote</button>
{open && (
<div className="mynote-modal" role="dialog" aria-modal="true" aria-label="MyNote" onClick={() => setOpen(false)}>
<div className="mynote-modal-panel" onClick={e => e.stopPropagation()}>
<button className="mynote-modal-close" type="button" onClick={() => setOpen(false)} aria-label="Close MyNote">×</button>
{editor}
</div>
</div>
)}
</div>
)
}
return (
<div className={`mynote-tab ${open ? 'open' : ''}`}>
{!open ? (
<button className="mynote-tab-button" type="button" onClick={openNote}>MyNote</button>
) : editor}
</div>
)
}

View file

@ -298,6 +298,133 @@ body {
line-height: 1;
cursor: pointer;
}
.mynote-tab {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 45;
}
.mynote-tab-button {
border: 1px solid rgba(37, 99, 235, 0.22);
border-radius: 999px;
padding: 11px 18px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white;
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.28);
cursor: pointer;
font-weight: 800;
letter-spacing: -0.02em;
}
.mynote-editor {
width: min(420px, calc(100vw - 28px));
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 18px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 96%, transparent), var(--card-bg)),
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 42%);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
overflow: hidden;
}
.mynote-editor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
}
.mynote-title {
font-weight: 850;
font-size: 1rem;
letter-spacing: -0.03em;
color: var(--text);
}
.mynote-status {
margin-top: 2px;
color: var(--text-muted);
font-size: 0.75rem;
}
.mynote-close,
.mynote-modal-close {
border: 0;
background: var(--border);
color: var(--text);
border-radius: 999px;
cursor: pointer;
}
.mynote-close { padding: 6px 10px; font-size: 0.76rem; }
.mynote-error {
margin: 10px 16px 0;
color: var(--wrong-fg);
font-size: 0.8rem;
}
.mynote-editor textarea {
width: 100%;
min-height: 260px;
resize: vertical;
border: 0;
outline: 0;
padding: 16px;
background: transparent;
color: var(--text);
font: inherit;
line-height: 1.65;
}
.mynote-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border-left: 4px solid #7c3aed;
}
.mynote-card-copy { min-width: 0; }
.mynote-eyebrow {
margin-bottom: 4px;
color: #7c3aed;
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.mynote-card h2 { margin: 0 0 6px; }
.mynote-card p {
margin: 0 0 6px;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.mynote-card span {
color: var(--text-subtle);
font-size: 0.76rem;
}
.mynote-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.55);
}
.mynote-modal-panel {
position: relative;
width: min(720px, 100%);
}
.mynote-modal-panel .mynote-editor { width: 100%; }
.mynote-modal-panel .mynote-editor textarea { min-height: min(58vh, 520px); }
.mynote-modal-close {
position: absolute;
top: -12px;
right: -12px;
z-index: 1;
width: 34px;
height: 34px;
background: var(--navbar-bg);
color: white;
font-size: 1.2rem;
}
.manual-highlight-toolbar {
display: inline-flex;
gap: 6px;
@ -515,6 +642,23 @@ body {
min-height: 36px;
white-space: normal;
}
.mynote-tab {
right: 12px;
bottom: 12px;
}
.mynote-tab.open {
left: 10px;
}
.mynote-editor textarea {
min-height: 220px;
}
.mynote-card {
align-items: stretch;
flex-direction: column;
}
.mynote-card .btn {
justify-content: center;
}
}
/* ── Lesson content (markdown/HTML) ───────────────────────────── */

View file

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import api from '../api/client'
import LineChart from '../components/LineChart'
import InProgressQuizzes from '../components/InProgressQuizzes'
import MyNote from '../components/MyNote'
import { useAuth } from '../context/AuthContext'
function greeting(name) {
@ -77,6 +78,8 @@ export default function DashboardPage() {
<InProgressQuizzes />
<MyNote variant="card" />
{/* Performance graph with dropdown */}
{history.length > 0 && (
<div className="card">

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import MyNote from '../components/MyNote'
const TeachChat = lazy(() => import('../components/TeachChat'))
@ -874,6 +875,7 @@ const timerStarted = timeLeft !== null
return (
<div className="quiz-bottom">
<MyNote variant="tab" />
{/* In-app leave confirmation */}
{leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>