diff --git a/README.md b/README.md index 899a3f5..08f2513 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 324ac60..1289767 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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 diff --git a/backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py b/backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py new file mode 100644 index 0000000..483675a --- /dev/null +++ b/backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py @@ -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') diff --git a/backend/app/main.py b/backend/app/main.py index 2e290b5..dc9a8a7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3a21511..17e36c9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8ad0955..7ab004f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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): diff --git a/backend/app/models/user_note.py b/backend/app/models/user_note.py new file mode 100644 index 0000000..473b869 --- /dev/null +++ b/backend/app/models/user_note.py @@ -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") diff --git a/backend/app/routers/mynote.py b/backend/app/routers/mynote.py new file mode 100644 index 0000000..d9dbcda --- /dev/null +++ b/backend/app/routers/mynote.py @@ -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} diff --git a/frontend/src/components/MyNote.jsx b/frontend/src/components/MyNote.jsx new file mode 100644 index 0000000..6fb0c89 --- /dev/null +++ b/frontend/src/components/MyNote.jsx @@ -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 = ( +
+
+
+
MyNote
+
{saving ? 'Saving...' : formatSavedAt(updatedAt)}
+
+ {variant === 'tab' && } +
+ {error &&
{error}
} +