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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```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
|
cd pdf-quiz-generator
|
||||||
|
|
||||||
# Configure environment
|
# Configure environment
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ if _db_url:
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
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
|
from app.database import Base
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
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
|
# Configure structured JSON logging before anything else
|
||||||
setup_logging(settings.LOG_LEVEL)
|
setup_logging(settings.LOG_LEVEL)
|
||||||
from app.database import engine, Base, SessionLocal
|
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.auth import get_password_hash
|
||||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
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(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
|
||||||
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
|
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
|
||||||
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
|
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
|
||||||
|
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from app.models.attempt import QuizAttempt, AttemptAnswer
|
||||||
from app.models.reminder import ReminderSchedule
|
from app.models.reminder import ReminderSchedule
|
||||||
from app.models.ai_model_config import AIModelConfig
|
from app.models.ai_model_config import AIModelConfig
|
||||||
from app.models.favorite import Favorite
|
from app.models.favorite import Favorite
|
||||||
|
from app.models.user_note import UserNote
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
|
@ -19,4 +20,5 @@ __all__ = [
|
||||||
"ReminderSchedule",
|
"ReminderSchedule",
|
||||||
"AIModelConfig",
|
"AIModelConfig",
|
||||||
"Favorite",
|
"Favorite",
|
||||||
|
"UserNote",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class User(Base):
|
||||||
attempts = relationship("QuizAttempt", back_populates="user")
|
attempts = relationship("QuizAttempt", back_populates="user")
|
||||||
reminders = relationship("ReminderSchedule", back_populates="user")
|
reminders = relationship("ReminderSchedule", back_populates="user")
|
||||||
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
|
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self):
|
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;
|
line-height: 1;
|
||||||
cursor: pointer;
|
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 {
|
.manual-highlight-toolbar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -515,6 +642,23 @@ body {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
white-space: normal;
|
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) ───────────────────────────── */
|
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import LineChart from '../components/LineChart'
|
import LineChart from '../components/LineChart'
|
||||||
import InProgressQuizzes from '../components/InProgressQuizzes'
|
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||||
|
import MyNote from '../components/MyNote'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
function greeting(name) {
|
function greeting(name) {
|
||||||
|
|
@ -77,6 +78,8 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
<InProgressQuizzes />
|
<InProgressQuizzes />
|
||||||
|
|
||||||
|
<MyNote variant="card" />
|
||||||
|
|
||||||
{/* Performance graph with dropdown */}
|
{/* Performance graph with dropdown */}
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<div className="card">
|
<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 { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
import MyNote from '../components/MyNote'
|
||||||
|
|
||||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||||
|
|
||||||
|
|
@ -874,6 +875,7 @@ const timerStarted = timeLeft !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="quiz-bottom">
|
<div className="quiz-bottom">
|
||||||
|
<MyNote variant="tab" />
|
||||||
{/* In-app leave confirmation */}
|
{/* In-app leave confirmation */}
|
||||||
{leaveTarget && (
|
{leaveTarget && (
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
<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