Add persistent MyNote for quizzes
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
This commit is contained in:
parent
4b75edef4c
commit
9f97218f39
12 changed files with 376 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py
Normal file
38
backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py
Normal 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')
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
18
backend/app/models/user_note.py
Normal file
18
backend/app/models/user_note.py
Normal 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")
|
||||
50
backend/app/routers/mynote.py
Normal file
50
backend/app/routers/mynote.py
Normal 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}
|
||||
114
frontend/src/components/MyNote.jsx
Normal file
114
frontend/src/components/MyNote.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) ───────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue