commit 132997c164480e0d23b6ab1b482255959c7b7423 Author: dvirlabs Date: Wed Feb 4 16:50:33 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9778fb9 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Database +DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/lomda + +# JWT Secrets (CHANGE THESE IN PRODUCTION!) +JWT_SECRET_KEY=change_me_access +JWT_REFRESH_SECRET_KEY=change_me_refresh +JWT_ALGORITHM=HS256 + +# Token Expiration +ACCESS_TOKEN_EXPIRES_MINUTES=30 +REFRESH_TOKEN_EXPIRES_DAYS=7 + +# Frontend URL +FRONTEND_URL=http://localhost:5173 + +# CORS Origins (comma-separated) +CORS_ORIGINS=http://localhost:5173 + +# Admin Seed Credentials +ADMIN_SEED_EMAIL=admin@example.com +ADMIN_SEED_PASSWORD=admin123 + +# Google OAuth (optional - get from Google Cloud Console) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback + +# Upload Directory +UPLOAD_DIR=/data/uploads + +# SMTP (optional, for future email features) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..21d4ee1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,91 @@ +# Lomda Hub - AI Coding Agent Instructions + +## Architecture Overview + +**Stack**: React+Vite (TypeScript) frontend, FastAPI backend, PostgreSQL database with SQLAlchemy 2.0 ORM, Alembic migrations. + +**Deployment**: Docker Compose with 3 services (db, backend, frontend). Frontend on 5173, backend on 8000, postgres on 5433. + +**Auth**: JWT access/refresh tokens + Google OAuth. Tokens passed via `Authorization: Bearer` header. Two roles: `admin` and `learner`. + +## Key Structural Patterns + +### Backend Structure +- **Models** ([backend/app/models.py](../backend/app/models.py)): SQLAlchemy 2.0 with type hints (`Mapped[T]`). Enums: `UserRole`, `ModuleType`, `ProgressStatus`, `QuestionType`. +- **Routers**: Three main routers in [backend/app/routers](../backend/app/routers): `auth.py` (login/register/OAuth), `admin.py` (course/module CRUD), `learner.py` (enrollments/progress). +- **Dependencies** ([backend/app/deps.py](../backend/app/deps.py)): `get_db()` for DB sessions, `get_current_user()` for auth, `require_admin()` for admin-only routes. +- **Config** ([backend/app/core/config.py](../backend/app/core/config.py)): Settings loaded from env vars using Pydantic `BaseSettings`. + +### Frontend Structure +- **Pages** ([frontend/src/pages](../frontend/src/pages)): Each major view (login, courses, player, admin dashboard/editor) as separate page component. +- **API Client** ([frontend/src/api](../frontend/src/api)): Centralized API calls. Token management in `client.ts` with localStorage. +- **Routing**: React Router v6 in [App.tsx](../frontend/src/App.tsx). Admin routes prefixed with `/admin`. + +### Data Flow: Locked Course Progression +Courses have ordered modules. Learner must complete module N before unlocking N+1: +1. On enrollment, first module is `unlocked`, rest are `locked` (see `ModuleProgress` model). +2. Content modules: mark completed when viewed. +3. Quiz modules: mark completed only if attempt passes (`score >= pass_score`). +4. Backend logic in [learner.py](../backend/app/routers/learner.py): `submit_quiz_attempt` updates progress and unlocks next module. + +## Critical Workflows + +### Database Migrations +1. After model changes: `cd backend && alembic revision --autogenerate -m "description"` +2. Apply: `alembic upgrade head` (or `docker compose restart backend` for Docker) +3. Seed data: `python -m app.scripts.seed` (creates admin user + sample data) + +### Development Commands +**Docker**: `docker compose up --build` (runs migrations + seed automatically via Dockerfile CMD) +**Local backend**: `uvicorn app.main:app --reload` (from backend dir with venv active) +**Local frontend**: `npm run dev` (from frontend dir) + +### Google OAuth Flow +- Login: GET `/auth/google/login` → redirects to Google → callback at `/auth/google/callback` +- Callback returns `RedirectResponse` to `FRONTEND_URL/auth/callback?access_token=...&refresh_token=...` +- Frontend ([GoogleCallbackPage.tsx](../frontend/src/pages/GoogleCallbackPage.tsx)) extracts tokens from URL params and saves to localStorage +- **Critical**: FastAPI route must NOT have trailing slash mismatch or it will 307 redirect infinitely + +## Project-Specific Conventions + +### API Response Patterns +- Use Pydantic schemas for request/response validation (see [schemas.py](../backend/app/schemas.py)) +- Admin endpoints return extended models with `group_ids` arrays +- Learner endpoints filter by current user (never expose other learners' data) + +### Error Handling +- Raise `HTTPException` with appropriate status codes (400 bad request, 401 unauthorized, 403 forbidden, 404 not found) +- No generic try/except unless specific recovery logic needed + +### Database Relationships +- Use SQLAlchemy relationship cascades: `cascade="all, delete-orphan"` for parent→child +- Unique constraints for enrollment, progress, group membership: prevent duplicates via `__table_args__` + +### Frontend State Management +- No global state library; use React hooks + fetch in components +- Token refresh: not auto-implemented; tokens expire after 30min (access) / 7days (refresh) + +## Integration Points + +### External Dependencies +- Google OAuth: requires `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` env vars +- SMTP: config present but not actively used (future email features) + +### Cross-Component Communication +- Frontend calls backend REST API; no WebSockets or real-time updates +- CORS: configured in [main.py](../backend/app/main.py) via `CORS_ORIGINS` env var (default: `http://localhost:5173`) + +## Common Gotchas + +1. **Trailing slashes**: FastAPI auto-redirects `/path/` to `/path` (307). Define routes without trailing slashes consistently. +2. **Password hashing**: bcrypt truncates at 72 bytes; see defensive check in [auth.py](../backend/app/auth.py). +3. **Module ordering**: `order_index` is manually managed. No auto-reordering on deletion. +4. **Admin seed**: Default admin created with email from `ADMIN_SEED_EMAIL` env var (default: `admin@example.com`). + +## Key Files to Reference + +- [models.py](../backend/app/models.py): All database schema +- [schemas.py](../backend/app/schemas.py): Request/response validation +- [deps.py](../backend/app/deps.py): Auth dependencies +- [learner.py](../backend/app/routers/learner.py): Core learning flow logic +- [App.tsx](../frontend/src/App.tsx): Frontend routing and navigation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e16ba99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +node_modules +__pycache__ +.venv +test/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cb0756 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# Lomda Hub (Mini-LMS) + +Mini לומדות platform with locked progression, groups, Google OAuth login, achievements system, file uploads, and admin analytics. + +## Features + +- **Locked Progression**: Learners must complete module N before unlocking N+1 +- **Google OAuth**: Sign in with Google account +- **JWT Authentication**: Access and refresh tokens for secure API access +- **Achievements System**: Earn achievements for completing courses, perfect scores, and consistent learning +- **File Uploads**: Admin can attach PDF/PPTX presentations to content modules +- **Admin Analytics**: View statistics on users, courses, enrollments, completions, and quiz attempts +- **Groups**: Restrict course access to specific user groups +- **Dark/Light Theme**: Toggle between dark and light modes + +## Stack +- Frontend: React + Vite + TypeScript + React Router + MUI +- Backend: FastAPI + SQLAlchemy 2.0 + Alembic +- Database: Postgres +- Auth: JWT (access + refresh) + Google OAuth +- Dev: Docker Compose + +## Quick start (Docker) + +1. Create a `.env` file (see `.env.example` for reference) or set environment variables +2. `docker compose build` +3. `docker compose up -d` + +Services: +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000 +- Backend API Docs: http://localhost:8000/docs +- Postgres: localhost:5433 + +Default admin credentials (seeded): +- Email: `admin@example.com` +- Password: `admin123` + +## Google OAuth setup (dev) + +1. Create OAuth credentials in [Google Cloud Console](https://console.cloud.google.com/). +2. Set Authorized redirect URI to `http://localhost:8000/auth/google/callback`. +3. Set the following in `.env`: + - `GOOGLE_CLIENT_ID=your_client_id` + - `GOOGLE_CLIENT_SECRET=your_client_secret` + - `GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback` + - `FRONTEND_URL=http://localhost:5173` + +## Environment Variables + +Create a `.env` file in the project root with the following variables (see `.env.example`): + +```bash +# Database +DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/lomda + +# JWT Secrets (change in production!) +JWT_SECRET_KEY=change_me_access +JWT_REFRESH_SECRET_KEY=change_me_refresh + +# Frontend URL +FRONTEND_URL=http://localhost:5173 + +# CORS Origins (comma-separated) +CORS_ORIGINS=http://localhost:5173 + +# Admin Seed Credentials +ADMIN_SEED_EMAIL=admin@example.com +ADMIN_SEED_PASSWORD=admin123 + +# Google OAuth (optional) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback + +# Upload Directory +UPLOAD_DIR=/data/uploads + +# SMTP (optional, for future email features) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +``` + +## Local development (no Docker) + +### Backend: +1. `cd backend` +2. `python -m venv .venv && .venv\Scripts\activate` (Windows) or `source .venv/bin/activate` (Linux/Mac) +3. `pip install -r requirements.txt` +4. `alembic upgrade head` +5. `python -m app.scripts.seed` +6. `uvicorn app.main:app --reload` + +### Frontend: +1. `cd frontend` +2. `npm install` +3. Create `frontend/.env` with: `VITE_API_URL=http://localhost:8000` +4. `npm run dev` + +## Testing Features + +### Google OAuth Login +1. Ensure `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set in `.env` +2. Visit http://localhost:5173/login +3. Click "Continue with Google" +4. Complete Google sign-in flow +5. You should be redirected back to the app authenticated + +### File Uploads +1. Login as admin (`admin@example.com` / `admin123`) +2. Navigate to Admin → Courses → Select a course → Modules +3. Edit a content module +4. Upload a PDF or PPTX presentation +5. Save the module +6. As a learner, enroll in the course and open that module +7. Click "View Presentation" to open the uploaded file + +### Achievements +1. Login as a learner +2. Navigate to "Achievements" in the header +3. Complete courses and quizzes to earn achievements: + - **First Course Complete**: Complete your first course + - **Perfect Final**: Score 100% on any final exam + - **Triple Perfect**: Score 100% on three quiz attempts in a row + - **Fast Finisher**: Complete a course within 24 hours of enrollment + - **Consistent Learner**: Log in on 5 different days + +### Admin Analytics +1. Login as admin +2. Navigate to Admin → Analytics +3. View user and course statistics +4. Click "View Details" to see detailed information on specific users or courses + +### Theme Toggle +- Click the sun/moon icon in the header to switch between light and dark themes +- Theme preference is saved in localStorage + +## Database Migrations + +After model changes: +1. `cd backend` +2. `alembic revision --autogenerate -m "description"` +3. `alembic upgrade head` + +In Docker, migrations run automatically on container startup. + +## Architecture Notes + +- **Locked Progression**: On enrollment, first module is `unlocked`, rest are `locked`. Content modules mark completed when viewed. Quiz modules mark completed only if score >= pass_score. +- **Admin vs Learner**: Admins can preview courses without enrolling. Learners must enroll and follow progression rules. +- **Groups**: Courses can be restricted to specific groups. If a course has no groups, it's public (all learners can see). +- **Uploads**: Stored on disk in `UPLOAD_DIR` (/data/uploads by default). Metadata tracked in database. Only authenticated users can download. +- **Achievements**: Evaluated after module completion and quiz submission. Uses login_events table to track consistent learner achievement. + +## Troubleshooting + +### Google OAuth 307 Redirects +- Ensure `GOOGLE_REDIRECT_URI` matches exactly what's configured in Google Cloud Console +- Backend uses `redirect_slashes=False` to prevent automatic trailing slash redirects + +### Uploads Not Working +- Ensure `UPLOAD_DIR` exists and backend has write permissions +- In Docker, uploads are stored in the `upload_data` volume + +### Dark Mode Not Persisting +- Check browser localStorage for `lomda_theme` key +- Clear localStorage and try toggling theme again + +## Environment +- Do not commit real secrets. Use `.env` locally and keep it in `.gitignore`. +- See `.env.example` for required values. + +## License +MIT + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9a6c021 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY ./app /app/app +COPY ./migrations /app/migrations +COPY ./alembic.ini /app/alembic.ini + +EXPOSE 8000 + +CMD ["bash", "-lc", "alembic upgrade head && python -m app.scripts.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..65d212d --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = migrations +sqlalchemy.url = postgresql+psycopg2://postgres:postgres@db:5432/lomda + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = console +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e02abfc --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/achievements.py b/backend/app/achievements.py new file mode 100644 index 0000000..8517706 --- /dev/null +++ b/backend/app/achievements.py @@ -0,0 +1,137 @@ +"""Achievement evaluation and awarding logic""" +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from sqlalchemy import func, distinct + +from app.models import ( + User, + Achievement, + UserAchievement, + QuizAttempt, + LoginEvent, + Enrollment, + ModuleProgress, + Course, + Module, + ProgressStatus, +) + + +def check_and_award_achievements(db: Session, user_id: int): + """Check and award all eligible achievements for a user""" + user = db.get(User, user_id) + if not user: + return + + # Get all achievements + all_achievements = db.query(Achievement).all() + existing_user_achievements = { + ua.achievement_id + for ua in db.query(UserAchievement).filter(UserAchievement.user_id == user_id).all() + } + + for achievement in all_achievements: + if achievement.id in existing_user_achievements: + continue + + earned = False + + if achievement.code == "FIRST_COURSE_COMPLETE": + # Check if user has completed any course + completed_count = ( + db.query(func.count(distinct(ModuleProgress.module_id))) + .join(Module, Module.id == ModuleProgress.module_id) + .filter( + ModuleProgress.user_id == user_id, + ModuleProgress.status == ProgressStatus.completed, + ) + .scalar() + ) + # Check if all modules in at least one course are completed + user_courses = ( + db.query(Course.id) + .join(Enrollment, Enrollment.course_id == Course.id) + .filter(Enrollment.user_id == user_id) + .all() + ) + for (course_id,) in user_courses: + total_modules = db.query(func.count(Module.id)).filter(Module.course_id == course_id).scalar() + completed_modules = ( + db.query(func.count(Module.id)) + .join(ModuleProgress, ModuleProgress.module_id == Module.id) + .filter( + Module.course_id == course_id, + ModuleProgress.user_id == user_id, + ModuleProgress.status == ProgressStatus.completed, + ) + .scalar() + ) + if total_modules > 0 and completed_modules >= total_modules: + earned = True + break + + elif achievement.code == "PERFECT_FINAL": + # Check if any quiz attempt has score 100 + perfect = ( + db.query(QuizAttempt) + .filter(QuizAttempt.user_id == user_id, QuizAttempt.score == 100.0) + .first() + ) + earned = perfect is not None + + elif achievement.code == "THREE_PERFECTS_ROW": + # Check last 3 attempts for consecutive 100s + attempts = ( + db.query(QuizAttempt) + .filter(QuizAttempt.user_id == user_id) + .order_by(QuizAttempt.submitted_at.desc()) + .limit(3) + .all() + ) + if len(attempts) >= 3 and all(a.score == 100.0 for a in attempts): + earned = True + + elif achievement.code == "FAST_FINISHER": + # Check if any course was completed within 24h of enrollment + user_enrollments = ( + db.query(Enrollment) + .filter(Enrollment.user_id == user_id) + .all() + ) + for enrollment in user_enrollments: + total_modules = ( + db.query(func.count(Module.id)) + .filter(Module.course_id == enrollment.course_id) + .scalar() + ) + completed_modules = ( + db.query(ModuleProgress) + .join(Module, Module.id == ModuleProgress.module_id) + .filter( + Module.course_id == enrollment.course_id, + ModuleProgress.user_id == user_id, + ModuleProgress.status == ProgressStatus.completed, + ) + .all() + ) + if total_modules > 0 and len(completed_modules) >= total_modules: + # Check if last completion was within 24h of enrollment + last_completion = max(m.completed_at for m in completed_modules if m.completed_at) + if last_completion and (last_completion - enrollment.enrolled_at) <= timedelta(hours=24): + earned = True + break + + elif achievement.code == "CONSISTENT_LEARNER": + # Check if user has logged in on 5 different days + distinct_days = ( + db.query(func.date(LoginEvent.logged_in_at)) + .filter(LoginEvent.user_id == user_id) + .distinct() + .count() + ) + earned = distinct_days >= 5 + + if earned: + db.add(UserAchievement(user_id=user_id, achievement_id=achievement.id)) + + db.commit() diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..ab16bcd --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,37 @@ +from datetime import datetime, timedelta +from typing import Any, Optional + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + # bcrypt limits to 72 bytes; truncate defensively to avoid runtime errors + if len(password.encode("utf-8")) > 72: + password = password.encode("utf-8")[:72].decode("utf-8", errors="ignore") + return pwd_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return pwd_context.verify(password, password_hash) + + +def create_access_token(subject: str, expires_minutes: Optional[int] = None) -> str: + expire = datetime.utcnow() + timedelta(minutes=expires_minutes or settings.access_token_expires_minutes) + payload = {"sub": subject, "exp": expire, "type": "access"} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def create_refresh_token(subject: str, expires_days: Optional[int] = None) -> str: + expire = datetime.utcnow() + timedelta(days=expires_days or settings.refresh_token_expires_days) + payload = {"sub": subject, "exp": expire, "type": "refresh"} + return jwt.encode(payload, settings.jwt_refresh_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str, token_type: str) -> dict[str, Any]: + secret = settings.jwt_secret_key if token_type == "access" else settings.jwt_refresh_secret_key + return jwt.decode(token, secret, algorithms=[settings.jwt_algorithm]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..2ccb5ac --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,29 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "postgresql+psycopg2://postgres:postgres@db:5432/lomda" + jwt_secret_key: str = "change_me_access" + jwt_refresh_secret_key: str = "change_me_refresh" + jwt_algorithm: str = "HS256" + access_token_expires_minutes: int = 30 + refresh_token_expires_days: int = 7 + cors_origins: str = "http://localhost:5173" + admin_seed_email: str = "admin@lomda.local" + admin_seed_password: str = "admin123" + frontend_url: str = "http://localhost:5173" + google_client_id: str = "" + google_client_secret: str = "" + google_redirect_uri: str = "http://localhost:8000/auth/google/callback" + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + upload_dir: str = "/data/uploads" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..11ea810 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,12 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..0ea2e4a --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,42 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError +from sqlalchemy.orm import Session + +from app.auth import decode_token +from app.db import SessionLocal +from app.models import User, UserRole + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + try: + payload = decode_token(token, "access") + if payload.get("type") != "access": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user_id = int(payload.get("sub")) + except (JWTError, ValueError): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User inactive") + return user + + +def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != UserRole.admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only") + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..450e595 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, Depends, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.routers import auth, admin, learner +from app.deps import get_current_user, get_db +from app.models import Upload + +import uvicorn + +app = FastAPI(title="Lomda Hub", redirect_slashes=False) + +origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins or ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(admin.router) +app.include_router(learner.router) + + +@app.get("/") +def root(): + return {"status": "ok"} + + +@app.get("/me") +def me(user=Depends(get_current_user)): + return {"id": user.id, "email": user.email, "role": user.role} + + +@app.get("/uploads/{upload_id}") +def serve_upload(upload_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)): + upload = db.get(Upload, upload_id) + if not upload: + raise HTTPException(status_code=404, detail="Upload not found") + return FileResponse(upload.file_path, media_type=upload.content_type, filename=upload.filename) + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..9ce46d4 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import List, Optional + +from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, Integer, String, Text, UniqueConstraint, Float, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db import Base + + +class UserRole(str, Enum): + admin = "admin" + learner = "learner" + + +class ModuleType(str, Enum): + content = "content" + quiz = "quiz" + + +class ProgressStatus(str, Enum): + locked = "locked" + unlocked = "unlocked" + completed = "completed" + + +class QuestionType(str, Enum): + mcq = "mcq" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + role: Mapped[UserRole] = mapped_column(SAEnum(UserRole, name="role_enum"), default=UserRole.learner) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + oauth_provider: Mapped[Optional[str]] = mapped_column(String(50)) + oauth_subject: Mapped[Optional[str]] = mapped_column(String(255)) + picture_url: Mapped[Optional[str]] = mapped_column(String(512)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + enrollments: Mapped[List[Enrollment]] = relationship("Enrollment", back_populates="user") + group_memberships: Mapped[List[GroupMembership]] = relationship("GroupMembership", back_populates="user") + + +class Course(Base): + __tablename__ = "courses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(255)) + description: Mapped[Optional[str]] = mapped_column(Text) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + modules: Mapped[List[Module]] = relationship("Module", back_populates="course", cascade="all, delete-orphan") + course_groups: Mapped[List[CourseGroup]] = relationship("CourseGroup", back_populates="course", cascade="all, delete-orphan") + + +class Module(Base): + __tablename__ = "modules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + course_id: Mapped[int] = mapped_column(ForeignKey("courses.id", ondelete="CASCADE"), index=True) + order_index: Mapped[int] = mapped_column(Integer) + type: Mapped[ModuleType] = mapped_column(SAEnum(ModuleType, name="module_type_enum")) + title: Mapped[str] = mapped_column(String(255)) + content_text: Mapped[Optional[str]] = mapped_column(Text) + pass_score: Mapped[Optional[int]] = mapped_column(Integer) + upload_id: Mapped[Optional[int]] = mapped_column(ForeignKey("uploads.id", ondelete="SET NULL"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + course: Mapped[Course] = relationship("Course", back_populates="modules") + questions: Mapped[List[QuizQuestion]] = relationship("QuizQuestion", back_populates="module", cascade="all, delete-orphan") + upload: Mapped[Optional["Upload"]] = relationship("Upload", back_populates="modules") + + +class QuizQuestion(Base): + __tablename__ = "quiz_questions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + module_id: Mapped[int] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), index=True) + prompt: Mapped[str] = mapped_column(Text) + question_type: Mapped[QuestionType] = mapped_column(SAEnum(QuestionType, name="question_type_enum"), default=QuestionType.mcq) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + module: Mapped[Module] = relationship("Module", back_populates="questions") + choices: Mapped[List[QuizChoice]] = relationship("QuizChoice", back_populates="question", cascade="all, delete-orphan") + + +class QuizChoice(Base): + __tablename__ = "quiz_choices" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + question_id: Mapped[int] = mapped_column(ForeignKey("quiz_questions.id", ondelete="CASCADE"), index=True) + text: Mapped[str] = mapped_column(Text) + is_correct: Mapped[bool] = mapped_column(Boolean, default=False) + + question: Mapped[QuizQuestion] = relationship("QuizQuestion", back_populates="choices") + + +class Enrollment(Base): + __tablename__ = "enrollments" + __table_args__ = (UniqueConstraint("user_id", "course_id", name="uq_enrollment"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + course_id: Mapped[int] = mapped_column(ForeignKey("courses.id", ondelete="CASCADE"), index=True) + enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped[User] = relationship("User", back_populates="enrollments") + course: Mapped[Course] = relationship("Course") + + +class ModuleProgress(Base): + __tablename__ = "module_progress" + __table_args__ = (UniqueConstraint("user_id", "module_id", name="uq_module_progress"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + module_id: Mapped[int] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), index=True) + status: Mapped[ProgressStatus] = mapped_column(SAEnum(ProgressStatus, name="progress_status_enum"), default=ProgressStatus.locked) + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + score: Mapped[Optional[float]] = mapped_column(Float) + + +class QuizAttempt(Base): + __tablename__ = "quiz_attempts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + module_id: Mapped[int] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), index=True) + submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + score: Mapped[float] = mapped_column(Float) + passed: Mapped[bool] = mapped_column(Boolean) + answers_json: Mapped[dict] = mapped_column(JSON) + + +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(255), unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + memberships: Mapped[List[GroupMembership]] = relationship("GroupMembership", back_populates="group", cascade="all, delete-orphan") + course_groups: Mapped[List[CourseGroup]] = relationship("CourseGroup", back_populates="group", cascade="all, delete-orphan") + + +class GroupMembership(Base): + __tablename__ = "group_memberships" + __table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_group_membership"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + group: Mapped[Group] = relationship("Group", back_populates="memberships") + user: Mapped[User] = relationship("User", back_populates="group_memberships") + + +class CourseGroup(Base): + __tablename__ = "course_groups" + __table_args__ = (UniqueConstraint("course_id", "group_id", name="uq_course_group"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + course_id: Mapped[int] = mapped_column(ForeignKey("courses.id", ondelete="CASCADE"), index=True) + group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), index=True) + + course: Mapped[Course] = relationship("Course", back_populates="course_groups") + group: Mapped[Group] = relationship("Group", back_populates="course_groups") + + +class Upload(Base): + __tablename__ = "uploads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + filename: Mapped[str] = mapped_column(String(255)) + content_type: Mapped[str] = mapped_column(String(100)) + file_path: Mapped[str] = mapped_column(String(512)) + size_bytes: Mapped[int] = mapped_column(Integer) + uploaded_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + uploader: Mapped[User] = relationship("User") + modules: Mapped[List[Module]] = relationship("Module", back_populates="upload") + + +class Achievement(Base): + __tablename__ = "achievements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + code: Mapped[str] = mapped_column(String(100), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[Optional[str]] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user_achievements: Mapped[List["UserAchievement"]] = relationship("UserAchievement", back_populates="achievement", cascade="all, delete-orphan") + + +class UserAchievement(Base): + __tablename__ = "user_achievements" + __table_args__ = (UniqueConstraint("user_id", "achievement_id", name="uq_user_achievement"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + achievement_id: Mapped[int] = mapped_column(ForeignKey("achievements.id", ondelete="CASCADE"), index=True) + earned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped[User] = relationship("User") + achievement: Mapped[Achievement] = relationship("Achievement", back_populates="user_achievements") + + +class LoginEvent(Base): + __tablename__ = "login_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + logged_in_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped[User] = relationship("User") + + diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..21910e4 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,3 @@ +from app.routers import auth, admin, learner + +__all__ = ["auth", "admin", "learner"] diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..e888c87 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,570 @@ +from typing import List, Dict, Any +import os +import secrets +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.core.config import settings +from app.deps import get_db, require_admin, get_current_user +from app.models import Course, Module, QuizQuestion, QuizChoice, CourseGroup, Group, GroupMembership, User, UserRole, Upload, Enrollment, ModuleProgress, QuizAttempt, ProgressStatus +from app.schemas import ( + AdminCourseCreate, + AdminCourseOut, + AdminCourseUpdate, + AdminUserOut, + AdminUserUpdate, + GroupCreate, + GroupOut, + GroupUpdate, + GroupMemberAdd, + ModuleCreate, + ModuleOut, + ModuleUpdate, + QuizQuestionCreate, + QuizQuestionOut, + QuizQuestionUpdate, + UploadOut, +) + +router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) + + +@router.get("/courses", response_model=List[AdminCourseOut]) +def list_courses(db: Session = Depends(get_db)): + courses = db.query(Course).order_by(Course.created_at.desc()).all() + results = [] + for course in courses: + group_ids = [cg.group_id for cg in course.course_groups] + results.append( + AdminCourseOut( + id=course.id, + title=course.title, + description=course.description, + is_published=course.is_published, + created_at=course.created_at, + updated_at=course.updated_at, + group_ids=group_ids, + ) + ) + return results + + +@router.post("/courses", response_model=AdminCourseOut) +def create_course(payload: AdminCourseCreate, db: Session = Depends(get_db)): + course = Course(**payload.model_dump(exclude={"group_ids"})) + db.add(course) + db.commit() + db.refresh(course) + if payload.group_ids: + for gid in payload.group_ids: + db.add(CourseGroup(course_id=course.id, group_id=gid)) + db.commit() + db.refresh(course) + return AdminCourseOut( + id=course.id, + title=course.title, + description=course.description, + is_published=course.is_published, + created_at=course.created_at, + updated_at=course.updated_at, + group_ids=[cg.group_id for cg in course.course_groups], + ) + + +@router.put("/courses/{course_id}", response_model=AdminCourseOut) +def update_course(course_id: int, payload: AdminCourseUpdate, db: Session = Depends(get_db)): + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + data = payload.model_dump(exclude_unset=True) + group_ids = data.pop("group_ids", None) + for key, value in data.items(): + setattr(course, key, value) + if group_ids is not None: + course.course_groups.clear() + for gid in group_ids: + course.course_groups.append(CourseGroup(group_id=gid)) + db.commit() + db.refresh(course) + return AdminCourseOut( + id=course.id, + title=course.title, + description=course.description, + is_published=course.is_published, + created_at=course.created_at, + updated_at=course.updated_at, + group_ids=[cg.group_id for cg in course.course_groups], + ) + + +@router.delete("/courses/{course_id}") +def delete_course(course_id: int, db: Session = Depends(get_db)): + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + db.delete(course) + db.commit() + return {"ok": True} + + +@router.patch("/courses/{course_id}/publish", response_model=AdminCourseOut) +def toggle_publish(course_id: int, db: Session = Depends(get_db)): + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + course.is_published = not course.is_published + db.commit() + db.refresh(course) + return AdminCourseOut( + id=course.id, + title=course.title, + description=course.description, + is_published=course.is_published, + created_at=course.created_at, + updated_at=course.updated_at, + group_ids=[cg.group_id for cg in course.course_groups], + ) + + +@router.get("/courses/{course_id}/modules", response_model=List[ModuleOut]) +def list_modules(course_id: int, db: Session = Depends(get_db)): + return ( + db.query(Module) + .filter(Module.course_id == course_id) + .order_by(Module.order_index) + .all() + ) + + +@router.post("/courses/{course_id}/modules", response_model=ModuleOut) +def create_module(course_id: int, payload: ModuleCreate, db: Session = Depends(get_db)): + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + module = Module(course_id=course_id, **payload.model_dump()) + db.add(module) + db.commit() + db.refresh(module) + return module + + +@router.put("/modules/{module_id}", response_model=ModuleOut) +def update_module(module_id: int, payload: ModuleUpdate, db: Session = Depends(get_db)): + module = db.get(Module, module_id) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(module, key, value) + db.commit() + db.refresh(module) + return module + + +@router.get("/modules/{module_id}", response_model=ModuleOut) +def get_module(module_id: int, db: Session = Depends(get_db)): + module = db.get(Module, module_id) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + return module + + +@router.get("/users", response_model=List[AdminUserOut]) +def list_users(q: str | None = None, db: Session = Depends(get_db)): + query = db.query(User) + if q: + query = query.filter(User.email.ilike(f"%{q}%")) + return query.order_by(User.created_at.desc()).all() + + +@router.get("/users/{user_id}", response_model=AdminUserOut) +def get_user(user_id: int, db: Session = Depends(get_db)): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/users/{user_id}", response_model=AdminUserOut) +def update_user(user_id: int, payload: AdminUserUpdate, db: Session = Depends(get_db)): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + data = payload.model_dump(exclude_unset=True) + if "role" in data and data["role"] in [UserRole.admin, UserRole.learner, "admin", "learner"]: + user.role = UserRole(data["role"]) + if "is_active" in data: + user.is_active = data["is_active"] + db.commit() + db.refresh(user) + return user + + +@router.get("/groups", response_model=List[GroupOut]) +def list_groups(db: Session = Depends(get_db)): + return db.query(Group).order_by(Group.created_at.desc()).all() + + +@router.post("/groups", response_model=GroupOut) +def create_group(payload: GroupCreate, db: Session = Depends(get_db)): + existing = db.query(Group).filter(Group.name == payload.name).first() + if existing: + raise HTTPException(status_code=400, detail="Group name already exists") + group = Group(name=payload.name) + db.add(group) + db.commit() + db.refresh(group) + return group + + +@router.put("/groups/{group_id}", response_model=GroupOut) +def update_group(group_id: int, payload: GroupUpdate, db: Session = Depends(get_db)): + group = db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(group, key, value) + db.commit() + db.refresh(group) + return group + + +@router.delete("/groups/{group_id}") +def delete_group(group_id: int, db: Session = Depends(get_db)): + group = db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + db.delete(group) + db.commit() + return {"ok": True} + + +@router.get("/groups/{group_id}/members", response_model=List[AdminUserOut]) +def list_group_members(group_id: int, db: Session = Depends(get_db)): + group = db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + members = ( + db.query(User) + .join(GroupMembership, GroupMembership.user_id == User.id) + .filter(GroupMembership.group_id == group_id) + .all() + ) + return members + + +@router.post("/groups/{group_id}/members") +def add_group_member(group_id: int, payload: GroupMemberAdd, db: Session = Depends(get_db)): + group = db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + user = None + if payload.user_id: + user = db.get(User, payload.user_id) + if not user and payload.email: + user = db.query(User).filter(User.email == payload.email).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + existing = ( + db.query(GroupMembership) + .filter(GroupMembership.group_id == group_id, GroupMembership.user_id == user.id) + .first() + ) + if existing: + return {"ok": True} + db.add(GroupMembership(group_id=group_id, user_id=user.id)) + db.commit() + return {"ok": True} + + +@router.delete("/groups/{group_id}/members/{user_id}") +def remove_group_member(group_id: int, user_id: int, db: Session = Depends(get_db)): + membership = ( + db.query(GroupMembership) + .filter(GroupMembership.group_id == group_id, GroupMembership.user_id == user_id) + .first() + ) + if not membership: + raise HTTPException(status_code=404, detail="Membership not found") + db.delete(membership) + db.commit() + return {"ok": True} + + +@router.delete("/modules/{module_id}") +def delete_module(module_id: int, db: Session = Depends(get_db)): + module = db.get(Module, module_id) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + db.delete(module) + db.commit() + return {"ok": True} + + +@router.get("/modules/{module_id}/questions", response_model=List[QuizQuestionOut]) +def list_questions(module_id: int, db: Session = Depends(get_db)): + return ( + db.query(QuizQuestion) + .filter(QuizQuestion.module_id == module_id) + .all() + ) + + +@router.post("/modules/{module_id}/questions", response_model=QuizQuestionOut) +def create_question(module_id: int, payload: QuizQuestionCreate, db: Session = Depends(get_db)): + module = db.get(Module, module_id) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + question = QuizQuestion(module_id=module_id, prompt=payload.prompt, question_type=payload.question_type) + db.add(question) + db.flush() + for choice in payload.choices: + db.add(QuizChoice(question_id=question.id, text=choice.text, is_correct=choice.is_correct)) + db.commit() + db.refresh(question) + return question + + +@router.put("/questions/{question_id}", response_model=QuizQuestionOut) +def update_question(question_id: int, payload: QuizQuestionUpdate, db: Session = Depends(get_db)): + question = db.get(QuizQuestion, question_id) + if not question: + raise HTTPException(status_code=404, detail="Question not found") + data = payload.model_dump(exclude_unset=True) + if "prompt" in data: + question.prompt = data["prompt"] + if "question_type" in data: + question.question_type = data["question_type"] + if "choices" in data: + question.choices.clear() + for choice in data["choices"] or []: + question.choices.append(QuizChoice(text=choice.text, is_correct=choice.is_correct)) + db.commit() + db.refresh(question) + return question + + +@router.delete("/questions/{question_id}") +def delete_question(question_id: int, db: Session = Depends(get_db)): + question = db.get(QuizQuestion, question_id) + if not question: + raise HTTPException(status_code=404, detail="Question not found") + db.delete(question) + db.commit() + return {"ok": True} + + +@router.post("/uploads", response_model=UploadOut) +async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(get_current_user)): + # Validate file type (PDF or PPTX only) + allowed_types = ["application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.ms-powerpoint"] + if file.content_type not in allowed_types: + raise HTTPException(status_code=400, detail="Only PDF and PPTX files are allowed") + + # Ensure upload directory exists + upload_dir = Path(settings.upload_dir) + upload_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_ext = Path(file.filename).suffix + unique_name = f"{secrets.token_urlsafe(16)}{file_ext}" + file_path = upload_dir / unique_name + + # Save file + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + # Store metadata in database + upload = Upload( + filename=file.filename, + content_type=file.content_type, + file_path=str(file_path), + size_bytes=len(content), + uploaded_by=user.id, + ) + db.add(upload) + db.commit() + db.refresh(upload) + return upload + + +@router.get("/uploads/{upload_id}") +def get_upload(upload_id: int, db: Session = Depends(get_db)): + upload = db.get(Upload, upload_id) + if not upload: + raise HTTPException(status_code=404, detail="Upload not found") + return upload + + +@router.get("/stats/users") +def stats_users(db: Session = Depends(get_db)) -> List[Dict[str, Any]]: + """Get statistics for all users""" + users = db.query(User).filter(User.role == UserRole.learner).all() + result = [] + for user in users: + enrollments_count = db.query(func.count(Enrollment.id)).filter(Enrollment.user_id == user.id).scalar() + completed_count = ( + db.query(func.count(func.distinct(ModuleProgress.module_id))) + .join(Module, Module.id == ModuleProgress.module_id) + .filter( + ModuleProgress.user_id == user.id, + ModuleProgress.status == ProgressStatus.completed, + ) + .scalar() + ) + attempts_count = db.query(func.count(QuizAttempt.id)).filter(QuizAttempt.user_id == user.id).scalar() + + result.append({ + "id": user.id, + "email": user.email, + "enrollments_count": enrollments_count, + "completed_modules_count": completed_count, + "attempts_count": attempts_count, + "created_at": user.created_at, + }) + return result + + +@router.get("/stats/users/{user_id}") +def stats_user(user_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]: + """Get detailed statistics for a specific user""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + enrollments = db.query(Enrollment).filter(Enrollment.user_id == user_id).all() + courses_data = [] + + for enrollment in enrollments: + course = db.get(Course, enrollment.course_id) + if not course: + continue + + modules = db.query(Module).filter(Module.course_id == course.id).order_by(Module.order_index).all() + progress_map = { + p.module_id: p + for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user_id).all() + } + + completed = [p for p in progress_map.values() if p.status == ProgressStatus.completed] + total_modules = len(modules) + completed_modules = len([m for m in modules if progress_map.get(m.id) and progress_map.get(m.id).status == ProgressStatus.completed]) + progress_percent = (completed_modules / total_modules * 100) if total_modules > 0 else 0 + + # Get quiz attempts for this course + attempts_data = [] + for module in modules: + if module.type.value == "quiz": + attempts = ( + db.query(QuizAttempt) + .filter(QuizAttempt.user_id == user_id, QuizAttempt.module_id == module.id) + .order_by(QuizAttempt.submitted_at.desc()) + .all() + ) + for attempt in attempts: + attempts_data.append({ + "module_id": module.id, + "module_title": module.title, + "score": attempt.score, + "passed": attempt.passed, + "submitted_at": attempt.submitted_at, + }) + + courses_data.append({ + "course_id": course.id, + "course_title": course.title, + "enrolled_at": enrollment.enrolled_at, + "progress_percent": progress_percent, + "completed_modules": completed_modules, + "total_modules": total_modules, + "attempts": attempts_data, + }) + + return { + "user_id": user_id, + "email": user.email, + "courses": courses_data, + } + + +@router.get("/stats/courses") +def stats_courses(db: Session = Depends(get_db)) -> List[Dict[str, Any]]: + """Get statistics for all courses""" + courses = db.query(Course).all() + result = [] + for course in courses: + enrollments_count = db.query(func.count(Enrollment.id)).filter(Enrollment.course_id == course.id).scalar() + + # Count users who completed all modules + modules = db.query(Module).filter(Module.course_id == course.id).all() + total_modules = len(modules) + + completions_count = 0 + if total_modules > 0: + enrollments = db.query(Enrollment).filter(Enrollment.course_id == course.id).all() + for enrollment in enrollments: + completed_modules = ( + db.query(func.count(ModuleProgress.id)) + .join(Module, Module.id == ModuleProgress.module_id) + .filter( + Module.course_id == course.id, + ModuleProgress.user_id == enrollment.user_id, + ModuleProgress.status == ProgressStatus.completed, + ) + .scalar() + ) + if completed_modules >= total_modules: + completions_count += 1 + + # Get final exam average (last module if it's a quiz) + final_exam_avg = None + if modules: + last_module = modules[-1] + if last_module.type.value == "quiz": + avg_score = db.query(func.avg(QuizAttempt.score)).filter(QuizAttempt.module_id == last_module.id).scalar() + final_exam_avg = float(avg_score) if avg_score else None + + result.append({ + "id": course.id, + "title": course.title, + "enrollments_count": enrollments_count, + "completions_count": completions_count, + "final_exam_avg_score": final_exam_avg, + }) + return result + + +@router.get("/stats/courses/{course_id}") +def stats_course(course_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]: + """Get detailed statistics for a specific course""" + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + enrollments = db.query(Enrollment).filter(Enrollment.course_id == course_id).all() + modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all() + + attempts_distribution = [] + for module in modules: + if module.type.value == "quiz": + attempts = db.query(QuizAttempt).filter(QuizAttempt.module_id == module.id).all() + attempts_distribution.append({ + "module_id": module.id, + "module_title": module.title, + "total_attempts": len(attempts), + "avg_score": sum(a.score for a in attempts) / len(attempts) if attempts else 0, + "pass_rate": sum(1 for a in attempts if a.passed) / len(attempts) * 100 if attempts else 0, + }) + + return { + "course_id": course.id, + "title": course.title, + "enrollments_count": len(enrollments), + "total_modules": len(modules), + "attempts_distribution": attempts_distribution, + } + + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..5035d0d --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,183 @@ +from datetime import datetime, timedelta +import secrets +import requests +import logging +from jose import jwt + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import RedirectResponse +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.auth import create_access_token, create_refresh_token, hash_password, verify_password, decode_token +from app.core.config import settings +from app.deps import get_db, get_current_user +from app.models import User, UserRole, LoginEvent +from app.schemas import TokenPair, UserCreate, UserOut, TokenRefresh + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserOut) +def register(payload: UserCreate, db: Session = Depends(get_db)): + existing = db.query(User).filter(User.email == payload.email).first() + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + user = User(email=payload.email, password_hash=hash_password(payload.password), role=UserRole.learner) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/login", response_model=TokenPair) +def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == form.username).first() + if not user or not verify_password(form.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User inactive") + + # Track login event + db.add(LoginEvent(user_id=user.id)) + db.commit() + + return TokenPair( + access_token=create_access_token(str(user.id)), + refresh_token=create_refresh_token(str(user.id)), + ) + + +@router.post("/refresh", response_model=TokenPair) +def refresh(payload: TokenRefresh): + decoded = decode_token(payload.refresh_token, "refresh") + if decoded.get("type") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") + user_id = decoded.get("sub") + return TokenPair( + access_token=create_access_token(str(user_id)), + refresh_token=create_refresh_token(str(user_id)), + ) + + +@router.get("/me", response_model=UserOut) +def me(user: User = Depends(get_current_user)): + return user + + +def build_google_oauth_url(state: str) -> str: + params = { + "client_id": settings.google_client_id, + "redirect_uri": settings.google_redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "access_type": "online", + "include_granted_scopes": "true", + "state": state, + "prompt": "select_account", + } + base = "https://accounts.google.com/o/oauth2/v2/auth" + query = "&".join([f"{k}={requests.utils.quote(str(v))}" for k, v in params.items()]) + return f"{base}?{query}" + + +@router.get("/google/login") +def google_login(): + if not settings.google_client_id or not settings.google_client_secret: + raise HTTPException(status_code=400, detail="Google OAuth not configured") + nonce = secrets.token_urlsafe(16) + state_payload = { + "nonce": nonce, + "exp": int((datetime.utcnow() + timedelta(minutes=10)).timestamp()), + "type": "oauth_state", + } + state = jwt.encode(state_payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + auth_url = build_google_oauth_url(state) + logger.info(f"Redirecting to Google OAuth: {auth_url[:100]}...") + return RedirectResponse(auth_url, status_code=302) + + +@router.get("/google/callback") +def google_callback(code: str | None = None, state: str | None = None, db: Session = Depends(get_db)): + logger.info("Google OAuth callback received") + if not code or not state: + logger.error(f"Missing code or state: code={bool(code)}, state={bool(state)}") + raise HTTPException(status_code=400, detail="Missing code or state") + try: + decoded = jwt.decode(state, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + except Exception as e: + logger.error(f"Failed to decode state: {e}") + raise HTTPException(status_code=400, detail="Invalid state") + if decoded.get("type") != "oauth_state": + logger.error("State type mismatch") + raise HTTPException(status_code=400, detail="Invalid state") + + token_res = requests.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "redirect_uri": settings.google_redirect_uri, + "grant_type": "authorization_code", + }, + timeout=10, + ) + if token_res.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to exchange code") + token_data = token_res.json() + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Missing access token") + + userinfo_res = requests.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + if userinfo_res.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to fetch user info") + profile = userinfo_res.json() + email = profile.get("email") + sub = profile.get("sub") + picture = profile.get("picture") + if not email: + raise HTTPException(status_code=400, detail="Email not available") + + user = db.query(User).filter(User.email == email).first() + if not user: + user = User( + email=email, + password_hash=hash_password(secrets.token_urlsafe(24)), + role=UserRole.learner, + oauth_provider="google", + oauth_subject=sub, + picture_url=picture, + ) + db.add(user) + db.commit() + db.refresh(user) + else: + if user.oauth_provider != "google": + user.oauth_provider = "google" + if not user.oauth_subject: + user.oauth_subject = sub + if picture and not user.picture_url: + user.picture_url = picture + db.commit() + db.refresh(user) + + # Track login event + db.add(LoginEvent(user_id=user.id)) + db.commit() + + token_pair = TokenPair( + access_token=create_access_token(str(user.id)), + refresh_token=create_refresh_token(str(user.id)), + ) + redirect_url = f"{settings.frontend_url}/auth/callback?access_token={token_pair.access_token}&refresh_token={token_pair.refresh_token}" + logger.info(f"OAuth successful for user {user.email}, redirecting to frontend") + return RedirectResponse(redirect_url, status_code=302) + diff --git a/backend/app/routers/learner.py b/backend/app/routers/learner.py new file mode 100644 index 0000000..3b3fb33 --- /dev/null +++ b/backend/app/routers/learner.py @@ -0,0 +1,282 @@ +from datetime import datetime +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.deps import get_db, get_current_user +from app.models import Course, Enrollment, Module, ModuleProgress, ModuleType, ProgressStatus, QuizAttempt, CourseGroup, GroupMembership, UserRole, Achievement, UserAchievement +from app.schemas import CourseOut, CourseWithModules, EnrollOut, ModuleWithProgress, QuizResult, QuizSubmit, ProgressOut, AchievementOut +from app.services.progression import ensure_module_progress, recompute_progress, complete_content_module +from app.services.quiz import grade_quiz +from app.achievements import check_and_award_achievements + +router = APIRouter(tags=["learner"]) + + +@router.get("/courses", response_model=List[CourseOut]) +def list_courses(db: Session = Depends(get_db), user=Depends(get_current_user)): + if user.role == UserRole.admin: + return db.query(Course).order_by(Course.created_at.desc()).all() + + user_group_ids = [ + gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all() + ] + courses = db.query(Course).filter(Course.is_published == True).order_by(Course.created_at.desc()).all() + allowed = [] + for course in courses: + group_ids = [cg.group_id for cg in course.course_groups] + if not group_ids: + allowed.append(course) + elif any(gid in user_group_ids for gid in group_ids): + allowed.append(course) + return allowed + + +@router.post("/courses/{course_id}/enroll", response_model=EnrollOut) +def enroll(course_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)): + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + if user.role != UserRole.admin: + if not course.is_published: + raise HTTPException(status_code=404, detail="Course not found") + group_ids = [cg.group_id for cg in course.course_groups] + if group_ids: + user_group_ids = [ + gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all() + ] + if not any(gid in user_group_ids for gid in group_ids): + raise HTTPException(status_code=403, detail="Not allowed") + existing = ( + db.query(Enrollment) + .filter(Enrollment.user_id == user.id, Enrollment.course_id == course_id) + .first() + ) + if existing: + return EnrollOut(course_id=course_id, enrolled_at=existing.enrolled_at) + enrollment = Enrollment(user_id=user.id, course_id=course_id) + db.add(enrollment) + db.flush() + ensure_module_progress(db, user.id, course_id) + recompute_progress(db, user.id, course_id) + db.refresh(enrollment) + return EnrollOut(course_id=course_id, enrolled_at=enrollment.enrolled_at) + + +@router.get("/courses/{course_id}", response_model=CourseWithModules) +def get_course(course_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)): + course = db.get(Course, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + if user.role != UserRole.admin: + if not course.is_published: + raise HTTPException(status_code=404, detail="Course not found") + group_ids = [cg.group_id for cg in course.course_groups] + if group_ids: + user_group_ids = [ + gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all() + ] + if not any(gid in user_group_ids for gid in group_ids): + raise HTTPException(status_code=403, detail="Not allowed") + enrollment = ( + db.query(Enrollment) + .filter(Enrollment.user_id == user.id, Enrollment.course_id == course_id) + .first() + ) + if not enrollment and user.role != UserRole.admin: + raise HTTPException(status_code=403, detail="Not enrolled") + if not enrollment and user.role == UserRole.admin: + # preview mode for admin without enrolling + modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all() + module_views: List[ModuleWithProgress] = [] + for idx, module in enumerate(modules): + status = ProgressStatus.unlocked if idx == 0 else ProgressStatus.locked + module_views.append( + ModuleWithProgress( + id=module.id, + order_index=module.order_index, + type=module.type, + title=module.title, + content_text=module.content_text, + pass_score=module.pass_score, + status=status, + completed_at=None, + score=None, + questions=module.questions, + ) + ) + return CourseWithModules( + id=course.id, + title=course.title, + description=course.description, + is_published=course.is_published, + modules=module_views, + ) + ensure_module_progress(db, user.id, course_id) + recompute_progress(db, user.id, course_id) + + modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all() + progress_map = { + p.module_id: p + for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user.id).all() + } + + module_views: List[ModuleWithProgress] = [] + for module in modules: + progress = progress_map.get(module.id) + module_views.append( + ModuleWithProgress( + id=module.id, + order_index=module.order_index, + type=module.type, + title=module.title, + content_text=module.content_text, + pass_score=module.pass_score, + status=progress.status if progress else ProgressStatus.locked, + completed_at=progress.completed_at if progress else None, + score=progress.score if progress else None, + questions=module.questions, + ) + ) + + return CourseWithModules( + id=course.id, + title=course.title, + description=course.description, + is_published=course.is_published, + modules=module_views, + ) + + +@router.post("/modules/{module_id}/complete", response_model=QuizResult) +def complete_module(module_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)): + module = db.get(Module, module_id) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + progress = ( + db.query(ModuleProgress) + .filter(ModuleProgress.user_id == user.id, ModuleProgress.module_id == module_id) + .first() + ) + if not progress or progress.status != ProgressStatus.unlocked: + raise HTTPException(status_code=403, detail="Module locked") + if module.type != ModuleType.content: + raise HTTPException(status_code=400, detail="Not a content module") + + complete_content_module(db, user.id, module) + recompute_progress(db, user.id, module.course_id) + check_and_award_achievements(db, user.id) + return QuizResult(score=100.0, passed=True) + + +@router.post("/modules/{module_id}/quiz/submit", response_model=QuizResult) +def submit_quiz(module_id: int, payload: QuizSubmit, db: Session = Depends(get_db), user=Depends(get_current_user)): + module = db.get(Module, module_id) + if not module or module.type != ModuleType.quiz: + raise HTTPException(status_code=404, detail="Quiz module not found") + progress = ( + db.query(ModuleProgress) + .filter(ModuleProgress.user_id == user.id, ModuleProgress.module_id == module_id) + .first() + ) + if not progress or progress.status != ProgressStatus.unlocked: + raise HTTPException(status_code=403, detail="Module locked") + + score, _, _ = grade_quiz(db, module, payload.answers) + pass_score = module.pass_score or 80 + passed = score >= pass_score + + attempt = QuizAttempt( + user_id=user.id, + module_id=module_id, + score=score, + passed=passed, + answers_json=payload.answers, + ) + db.add(attempt) + + progress.score = score + if passed: + progress.status = ProgressStatus.completed + progress.completed_at = datetime.utcnow() + db.commit() + + check_and_award_achievements(db, user.id) + recompute_progress(db, user.id, module.course_id) + return QuizResult(score=score, passed=passed) + + +@router.get("/progress", response_model=ProgressOut) +def overall_progress(db: Session = Depends(get_db), user=Depends(get_current_user)): + enrollments = db.query(Enrollment).filter(Enrollment.user_id == user.id).all() + courses_out = [] + + for enrollment in enrollments: + course = db.get(Course, enrollment.course_id) + if not course: + continue + modules = db.query(Module).filter(Module.course_id == course.id).order_by(Module.order_index).all() + if not modules: + continue + progress_map = { + p.module_id: p + for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user.id).all() + } + completed = [p for p in progress_map.values() if p.status == ProgressStatus.completed] + percent = (len(completed) / len(modules)) * 100 + + final_module = modules[-1] + final_progress = progress_map.get(final_module.id) + final_passed = final_progress.score is not None and final_progress.score >= (final_module.pass_score or 80) + if final_progress and final_progress.score is not None and not final_passed: + status_value = "failed" + elif final_progress and final_progress.status == ProgressStatus.completed and final_passed: + status_value = "passed" + elif percent >= 100: + status_value = "completed" + else: + status_value = "in_progress" + + completed_at = None + if completed: + completed_at = max([p.completed_at for p in completed if p.completed_at is not None], default=None) + + courses_out.append( + { + "course_id": course.id, + "title": course.title, + "percent": percent, + "status": status_value, + "final_exam_score": final_progress.score if final_progress else None, + "final_exam_passed": final_passed if final_progress else None, + "completed_at": completed_at, + } + ) + + + +@router.get("/me/achievements", response_model=List[AchievementOut]) +def my_achievements(db: Session = Depends(get_db), user=Depends(get_current_user)): + all_achievements = db.query(Achievement).all() + user_achievements = { + ua.achievement_id: ua + for ua in db.query(UserAchievement).filter(UserAchievement.user_id == user.id).all() + } + + result = [] + for achievement in all_achievements: + ua = user_achievements.get(achievement.id) + result.append( + AchievementOut( + id=achievement.id, + code=achievement.code, + name=achievement.name, + description=achievement.description, + earned=ua is not None, + earned_at=ua.earned_at if ua else None, + ) + ) + return result + + return {"courses": courses_out} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..4c70137 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional, Dict + +from pydantic import BaseModel, EmailStr, Field +from pydantic.config import ConfigDict + +from app.models import ModuleType, ProgressStatus, QuestionType + + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenRefresh(BaseModel): + refresh_token: str + + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: EmailStr + role: str + is_active: bool + picture_url: Optional[str] = None + + +class AdminUserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: EmailStr + role: str + is_active: bool + oauth_provider: Optional[str] = None + oauth_subject: Optional[str] = None + picture_url: Optional[str] = None + created_at: datetime + + +class AdminUserUpdate(BaseModel): + role: Optional[str] = None + is_active: Optional[bool] = None + + +class CourseCreate(BaseModel): + title: str + description: Optional[str] = None + is_published: bool = False + + +class CourseUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + is_published: Optional[bool] = None + + +class AdminCourseCreate(CourseCreate): + group_ids: List[int] = [] + + +class AdminCourseUpdate(CourseUpdate): + group_ids: Optional[List[int]] = None + + +class CourseOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + description: Optional[str] = None + is_published: bool + created_at: datetime + updated_at: datetime + + +class AdminCourseOut(CourseOut): + group_ids: List[int] = [] + + +class ModuleCreate(BaseModel): + order_index: int + type: ModuleType + title: str + content_text: Optional[str] = None + pass_score: Optional[int] = 80 + upload_id: Optional[int] = None + + +class ModuleUpdate(BaseModel): + order_index: Optional[int] = None + type: Optional[ModuleType] = None + title: Optional[str] = None + content_text: Optional[str] = None + pass_score: Optional[int] = None + upload_id: Optional[int] = None + + +class QuizChoiceCreate(BaseModel): + text: str + is_correct: bool = False + + +class QuizChoiceOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + text: str + is_correct: bool + + +class QuizChoicePublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + text: str + + +class QuizQuestionCreate(BaseModel): + prompt: str + question_type: QuestionType = QuestionType.mcq + choices: List[QuizChoiceCreate] + + +class QuizQuestionUpdate(BaseModel): + prompt: Optional[str] = None + question_type: Optional[QuestionType] = None + choices: Optional[List[QuizChoiceCreate]] = None + + +class QuizQuestionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + prompt: str + question_type: QuestionType + choices: List[QuizChoiceOut] + + +class QuizQuestionPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + prompt: str + question_type: QuestionType + choices: List[QuizChoicePublic] + + +class ModuleOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + course_id: int + order_index: int + type: ModuleType + title: str + content_text: Optional[str] = None + pass_score: Optional[int] = None + upload_id: Optional[int] = None + created_at: datetime + questions: List[QuizQuestionOut] = [] + + +class ModuleProgressOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + module_id: int + status: ProgressStatus + completed_at: Optional[datetime] = None + score: Optional[float] = None + + +class ModuleWithProgress(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + order_index: int + type: ModuleType + title: str + content_text: Optional[str] = None + pass_score: Optional[int] = None + upload_id: Optional[int] = None + status: ProgressStatus + completed_at: Optional[datetime] = None + score: Optional[float] = None + questions: List[QuizQuestionPublic] = [] + + +class CourseWithModules(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + description: Optional[str] = None + is_published: bool + modules: List[ModuleWithProgress] + + +class QuizSubmit(BaseModel): + answers: Dict[int, int] + + +class QuizResult(BaseModel): + score: float + passed: bool + + +class EnrollOut(BaseModel): + course_id: int + enrolled_at: datetime + + +class ProgressCourse(BaseModel): + course_id: int + title: str + percent: float + status: str + final_exam_score: Optional[float] = None + final_exam_passed: Optional[bool] = None + completed_at: Optional[datetime] = None + + +class ProgressOut(BaseModel): + courses: List[ProgressCourse] + + +class GroupCreate(BaseModel): + name: str + + +class GroupUpdate(BaseModel): + name: Optional[str] = None + + +class GroupOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + created_at: datetime + + +class GroupMemberAdd(BaseModel): + user_id: Optional[int] = None + email: Optional[EmailStr] = None + + +class UploadOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + filename: str + content_type: str + size_bytes: int + created_at: datetime + + +class AchievementOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + description: Optional[str] = None + earned_at: Optional[datetime] = None + earned: bool = False + diff --git a/backend/app/scripts/seed.py b/backend/app/scripts/seed.py new file mode 100644 index 0000000..112c0be --- /dev/null +++ b/backend/app/scripts/seed.py @@ -0,0 +1,111 @@ +from sqlalchemy.orm import Session + +from app.auth import hash_password +from app.core.config import settings +from app.db import SessionLocal +from app.models import Course, Module, ModuleType, QuizQuestion, QuizChoice, User, UserRole, Achievement + + +def seed(): + db: Session = SessionLocal() + try: + admin = db.query(User).filter(User.email == settings.admin_seed_email).first() + if not admin: + admin = User( + email=settings.admin_seed_email, + password_hash=hash_password(settings.admin_seed_password), + role=UserRole.admin, + ) + db.add(admin) + + # Seed achievements + achievements_data = [ + { + "code": "FIRST_COURSE_COMPLETE", + "name": "First Course Complete", + "description": "Complete your first course", + }, + { + "code": "PERFECT_FINAL", + "name": "Perfect Final", + "description": "Score 100% on any final exam", + }, + { + "code": "THREE_PERFECTS_ROW", + "name": "Triple Perfect", + "description": "Score 100% on three quiz attempts in a row", + }, + { + "code": "FAST_FINISHER", + "name": "Fast Finisher", + "description": "Complete a course within 24 hours of enrollment", + }, + { + "code": "CONSISTENT_LEARNER", + "name": "Consistent Learner", + "description": "Log in on 5 different days", + }, + ] + + for ach_data in achievements_data: + existing = db.query(Achievement).filter(Achievement.code == ach_data["code"]).first() + if not existing: + db.add(Achievement(**ach_data)) + + course = db.query(Course).filter(Course.title == "Demo Lomda").first() + if not course: + course = Course(title="Demo Lomda", description="Intro demo course", is_published=True) + db.add(course) + db.flush() + + module1 = Module( + course_id=course.id, + order_index=1, + type=ModuleType.content, + title="Welcome", + content_text="Welcome to the demo lomda. Mark complete to continue.", + ) + module2 = Module( + course_id=course.id, + order_index=2, + type=ModuleType.quiz, + title="Quick Check", + pass_score=80, + ) + module3 = Module( + course_id=course.id, + order_index=3, + type=ModuleType.quiz, + title="Final Exam", + pass_score=80, + ) + db.add_all([module1, module2, module3]) + db.flush() + + q1 = QuizQuestion(module_id=module2.id, prompt="What does LMS stand for?") + db.add(q1) + db.flush() + db.add_all( + [ + QuizChoice(question_id=q1.id, text="Learning Management System", is_correct=True), + QuizChoice(question_id=q1.id, text="Local Module Server", is_correct=False), + ] + ) + + q2 = QuizQuestion(module_id=module3.id, prompt="Final exam question: 2 + 2 = ?") + db.add(q2) + db.flush() + db.add_all( + [ + QuizChoice(question_id=q2.id, text="4", is_correct=True), + QuizChoice(question_id=q2.id, text="5", is_correct=False), + ] + ) + + db.commit() + finally: + db.close() + + +if __name__ == "__main__": + seed() diff --git a/backend/app/services/progression.py b/backend/app/services/progression.py new file mode 100644 index 0000000..c8bed3c --- /dev/null +++ b/backend/app/services/progression.py @@ -0,0 +1,68 @@ +from datetime import datetime +from typing import List + +from sqlalchemy.orm import Session + +from app.models import Module, ModuleProgress, ProgressStatus + + +def ensure_module_progress(db: Session, user_id: int, course_id: int) -> List[ModuleProgress]: + modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all() + progress_list: List[ModuleProgress] = [] + for module in modules: + progress = ( + db.query(ModuleProgress) + .filter(ModuleProgress.user_id == user_id, ModuleProgress.module_id == module.id) + .first() + ) + if not progress: + progress = ModuleProgress(user_id=user_id, module_id=module.id, status=ProgressStatus.locked) + db.add(progress) + db.flush() + progress_list.append(progress) + db.commit() + return progress_list + + +def recompute_progress(db: Session, user_id: int, course_id: int) -> None: + modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all() + progresses = { + p.module_id: p + for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user_id).all() + } + + last_completed_index = -1 + for idx, module in enumerate(modules): + progress = progresses.get(module.id) + if not progress: + progress = ModuleProgress(user_id=user_id, module_id=module.id, status=ProgressStatus.locked) + db.add(progress) + progresses[module.id] = progress + if progress.status == ProgressStatus.completed: + last_completed_index = idx + + for idx, module in enumerate(modules): + progress = progresses[module.id] + if progress.status == ProgressStatus.completed: + continue + if idx == last_completed_index + 1: + progress.status = ProgressStatus.unlocked + else: + progress.status = ProgressStatus.locked + + db.commit() + + +def complete_content_module(db: Session, user_id: int, module: Module) -> ModuleProgress: + progress = ( + db.query(ModuleProgress) + .filter(ModuleProgress.user_id == user_id, ModuleProgress.module_id == module.id) + .first() + ) + if not progress: + progress = ModuleProgress(user_id=user_id, module_id=module.id, status=ProgressStatus.unlocked) + db.add(progress) + progress.status = ProgressStatus.completed + progress.completed_at = datetime.utcnow() + db.commit() + return progress diff --git a/backend/app/services/quiz.py b/backend/app/services/quiz.py new file mode 100644 index 0000000..6fcfd9c --- /dev/null +++ b/backend/app/services/quiz.py @@ -0,0 +1,28 @@ +from typing import Dict, Tuple + +from sqlalchemy.orm import Session + +from app.models import Module, QuizQuestion, QuizChoice + + +def grade_quiz(db: Session, module: Module, answers: Dict[int, int]) -> Tuple[float, int, int]: + questions = ( + db.query(QuizQuestion) + .filter(QuizQuestion.module_id == module.id) + .all() + ) + if not questions: + return 0.0, 0, 0 + + correct = 0 + total = len(questions) + for q in questions: + choice_id = answers.get(q.id) + if choice_id is None: + continue + choice = db.get(QuizChoice, choice_id) + if choice and choice.question_id == q.id and choice.is_correct: + correct += 1 + + score = (correct / total) * 100 + return score, correct, total diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..4be82a7 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,57 @@ +from logging.config import fileConfig +import os +import sys + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) + +from app.core.config import settings +from app.db import Base +from app import models + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..e9a22e7 --- /dev/null +++ b/backend/migrations/versions/0001_initial.py @@ -0,0 +1,120 @@ +"""initial + +Revision ID: 0001_initial +Revises: +Create Date: 2026-02-03 18:30:00 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("role", sa.Enum("admin", "learner", name="role_enum"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + op.create_table( + "courses", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text()), + sa.Column("is_published", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "modules", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False), + sa.Column("order_index", sa.Integer(), nullable=False), + sa.Column("type", sa.Enum("content", "quiz", name="module_type_enum"), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("content_text", sa.Text()), + sa.Column("pass_score", sa.Integer()), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_modules_course_id"), "modules", ["course_id"]) + + op.create_table( + "quiz_questions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("module_id", sa.Integer(), sa.ForeignKey("modules.id", ondelete="CASCADE"), nullable=False), + sa.Column("prompt", sa.Text(), nullable=False), + sa.Column("question_type", sa.Enum("mcq", name="question_type_enum"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_quiz_questions_module_id"), "quiz_questions", ["module_id"]) + + op.create_table( + "quiz_choices", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("question_id", sa.Integer(), sa.ForeignKey("quiz_questions.id", ondelete="CASCADE"), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("is_correct", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.create_index(op.f("ix_quiz_choices_question_id"), "quiz_choices", ["question_id"]) + + op.create_table( + "enrollments", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False), + sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "course_id", name="uq_enrollment"), + ) + op.create_index(op.f("ix_enrollments_user_id"), "enrollments", ["user_id"]) + op.create_index(op.f("ix_enrollments_course_id"), "enrollments", ["course_id"]) + + op.create_table( + "module_progress", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("module_id", sa.Integer(), sa.ForeignKey("modules.id", ondelete="CASCADE"), nullable=False), + sa.Column("status", sa.Enum("locked", "unlocked", "completed", name="progress_status_enum"), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("score", sa.Float()), + sa.UniqueConstraint("user_id", "module_id", name="uq_module_progress"), + ) + op.create_index(op.f("ix_module_progress_user_id"), "module_progress", ["user_id"]) + op.create_index(op.f("ix_module_progress_module_id"), "module_progress", ["module_id"]) + + op.create_table( + "quiz_attempts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("module_id", sa.Integer(), sa.ForeignKey("modules.id", ondelete="CASCADE"), nullable=False), + sa.Column("submitted_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("score", sa.Float(), nullable=False), + sa.Column("passed", sa.Boolean(), nullable=False), + sa.Column("answers_json", sa.JSON(), nullable=False), + ) + op.create_index(op.f("ix_quiz_attempts_user_id"), "quiz_attempts", ["user_id"]) + op.create_index(op.f("ix_quiz_attempts_module_id"), "quiz_attempts", ["module_id"]) + + +def downgrade() -> None: + op.drop_table("quiz_attempts") + op.drop_table("module_progress") + op.drop_table("enrollments") + op.drop_table("quiz_choices") + op.drop_table("quiz_questions") + op.drop_table("modules") + op.drop_table("courses") + op.drop_table("users") + op.execute("DROP TYPE IF EXISTS role_enum") + op.execute("DROP TYPE IF EXISTS module_type_enum") + op.execute("DROP TYPE IF EXISTS progress_status_enum") + op.execute("DROP TYPE IF EXISTS question_type_enum") diff --git a/backend/migrations/versions/0002_groups_oauth.py b/backend/migrations/versions/0002_groups_oauth.py new file mode 100644 index 0000000..3769c2c --- /dev/null +++ b/backend/migrations/versions/0002_groups_oauth.py @@ -0,0 +1,60 @@ +"""groups_and_oauth + +Revision ID: 0002_groups_oauth +Revises: 0001_initial +Create Date: 2026-02-03 20:00:00 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "0002_groups_oauth" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())) + op.add_column("users", sa.Column("oauth_provider", sa.String(length=50), nullable=True)) + op.add_column("users", sa.Column("oauth_subject", sa.String(length=255), nullable=True)) + op.add_column("users", sa.Column("picture_url", sa.String(length=512), nullable=True)) + + op.create_table( + "groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_groups_name"), "groups", ["name"], unique=True) + + op.create_table( + "group_memberships", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("group_id", sa.Integer(), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("group_id", "user_id", name="uq_group_membership"), + ) + op.create_index(op.f("ix_group_memberships_group_id"), "group_memberships", ["group_id"]) + op.create_index(op.f("ix_group_memberships_user_id"), "group_memberships", ["user_id"]) + + op.create_table( + "course_groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False), + sa.Column("group_id", sa.Integer(), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False), + sa.UniqueConstraint("course_id", "group_id", name="uq_course_group"), + ) + op.create_index(op.f("ix_course_groups_course_id"), "course_groups", ["course_id"]) + op.create_index(op.f("ix_course_groups_group_id"), "course_groups", ["group_id"]) + + +def downgrade() -> None: + op.drop_table("course_groups") + op.drop_table("group_memberships") + op.drop_table("groups") + op.drop_column("users", "picture_url") + op.drop_column("users", "oauth_subject") + op.drop_column("users", "oauth_provider") + op.drop_column("users", "is_active") diff --git a/backend/migrations/versions/0003_uploads_achievements.py b/backend/migrations/versions/0003_uploads_achievements.py new file mode 100644 index 0000000..9235415 --- /dev/null +++ b/backend/migrations/versions/0003_uploads_achievements.py @@ -0,0 +1,75 @@ +"""add_uploads_and_achievements + +Revision ID: 0003_uploads_achievements +Revises: 0002_groups_oauth +Create Date: 2026-02-03 21:00:00 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "0003_uploads_achievements" +down_revision = "0002_groups_oauth" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create uploads table + op.create_table( + "uploads", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("filename", sa.String(length=255), nullable=False), + sa.Column("content_type", sa.String(length=100), nullable=False), + sa.Column("file_path", sa.String(length=512), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("uploaded_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_uploads_uploaded_by"), "uploads", ["uploaded_by"]) + + # Add upload_id to modules table + op.add_column("modules", sa.Column("upload_id", sa.Integer(), sa.ForeignKey("uploads.id", ondelete="SET NULL"), nullable=True)) + op.create_index(op.f("ix_modules_upload_id"), "modules", ["upload_id"]) + + # Create achievements table + op.create_table( + "achievements", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("code", sa.String(length=100), nullable=False, unique=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_achievements_code"), "achievements", ["code"], unique=True) + + # Create user_achievements table + op.create_table( + "user_achievements", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("achievement_id", sa.Integer(), sa.ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False), + sa.Column("earned_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "achievement_id", name="uq_user_achievement"), + ) + op.create_index(op.f("ix_user_achievements_user_id"), "user_achievements", ["user_id"]) + op.create_index(op.f("ix_user_achievements_achievement_id"), "user_achievements", ["achievement_id"]) + + # Create login_events table for tracking logins + op.create_table( + "login_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("logged_in_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index(op.f("ix_login_events_user_id"), "login_events", ["user_id"]) + op.create_index(op.f("ix_login_events_logged_in_at"), "login_events", ["logged_in_at"]) + + +def downgrade() -> None: + op.drop_table("login_events") + op.drop_table("user_achievements") + op.drop_table("achievements") + op.drop_index(op.f("ix_modules_upload_id"), "modules") + op.drop_column("modules", "upload_id") + op.drop_table("uploads") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a1e8072 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.32 +alembic==1.13.2 +psycopg2-binary==2.9.9 +pydantic==2.8.2 +pydantic-settings==2.4.0 +email-validator==2.2.0 +requests==2.32.3 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==3.2.0 +python-multipart==0.0.9 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f0d86d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: lomda + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/lomda + JWT_SECRET_KEY: change_me_access + JWT_REFRESH_SECRET_KEY: change_me_refresh + CORS_ORIGINS: http://localhost:5173 + ADMIN_SEED_EMAIL: admin@example.com + ADMIN_SEED_PASSWORD: admin123 + FRONTEND_URL: http://localhost:5173 + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URI: http://localhost:8000/auth/google/callback + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM: ${SMTP_FROM} + UPLOAD_DIR: /data/uploads + volumes: + - ./backend:/app + - upload_data:/data/uploads + ports: + - "8000:8000" + depends_on: + - db + + frontend: + build: ./frontend + environment: + VITE_API_URL: http://localhost:8000 + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:5173" + depends_on: + - backend + command: sh -c "npm install && npm run dev" + +volumes: + postgres_data: + upload_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..61f97c4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* /app/ +RUN npm install + +COPY . /app + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ddfd1e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Lomda Hub + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..39b26c2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2351 @@ +{ + "name": "lomda-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lomda-frontend", + "version": "0.0.1", + "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^5.16.7", + "@mui/material": "^5.16.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3d1070e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "lomda-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^5.16.7", + "@mui/material": "^5.16.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e79781b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,89 @@ +import { Route, Routes, Link as RouterLink, useNavigate, useLocation } from "react-router-dom"; +import { AppBar, Box, Button, Container, Toolbar, Typography, IconButton } from "@mui/material"; +import { Brightness4, Brightness7 } from "@mui/icons-material"; +import { useEffect, useState } from "react"; +import LoginPage from "./pages/LoginPage"; +import RegisterPage from "./pages/RegisterPage"; +import CoursesPage from "./pages/CoursesPage"; +import CoursePlayerPage from "./pages/CoursePlayerPage"; +import AdminDashboard from "./pages/AdminDashboard"; +import AdminCourseEditor from "./pages/AdminCourseEditor"; +import AdminModulesPage from "./pages/AdminModulesPage"; +import AdminModuleEdit from "./pages/AdminModuleEdit"; +import AdminUsersPage from "./pages/AdminUsersPage"; +import AdminGroupsPage from "./pages/AdminGroupsPage"; +import AdminStatsPage from "./pages/AdminStatsPage"; +import AchievementsPage from "./pages/AchievementsPage"; +import GoogleCallbackPage from "./pages/GoogleCallbackPage"; +import { clearTokens, getUserRole, getMe, getAccessToken } from "./api/client"; +import { useTheme } from "./ThemeProvider"; + +export default function App() { + const navigate = useNavigate(); + const location = useLocation(); + const role = getUserRole(); + const { mode, toggleTheme } = useTheme(); + const [userEmail, setUserEmail] = useState(null); + + useEffect(() => { + if (getAccessToken()) { + getMe().then(user => setUserEmail(user.email)).catch(() => setUserEmail(null)); + } + }, [location]); + + return ( + + + + + + Lomda Hub + + + {role === "learner" && } + {role === "admin" && ( + <> + + + + )} + + + {userEmail && ( + + Hello: {userEmail} + + )} + + {mode === "dark" ? : } + + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/frontend/src/ThemeProvider.tsx b/frontend/src/ThemeProvider.tsx new file mode 100644 index 0000000..2ed34c2 --- /dev/null +++ b/frontend/src/ThemeProvider.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; +import { createTheme, ThemeProvider as MuiThemeProvider, CssBaseline } from "@mui/material"; + +type ThemeMode = "light" | "dark"; + +interface ThemeContextType { + mode: ThemeMode; + toggleTheme: () => void; +} + +const ThemeContext = createContext({ + mode: "light", + toggleTheme: () => {}, +}); + +export const useTheme = () => useContext(ThemeContext); + +export const ThemeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setMode] = useState(() => { + const saved = localStorage.getItem("lomda_theme"); + return (saved as ThemeMode) || "light"; + }); + + const toggleTheme = () => { + setMode((prev: ThemeMode) => { + const newMode = prev === "light" ? "dark" : "light"; + localStorage.setItem("lomda_theme", newMode); + return newMode; + }); + }; + + const theme = createTheme({ + palette: { + mode, + primary: { + main: mode === "light" ? "#1976d2" : "#90caf9", + }, + background: { + default: mode === "light" ? "#f5f5f5" : "#121212", + paper: mode === "light" ? "#ffffff" : "#1e1e1e", + }, + }, + }); + + useEffect(() => { + document.body.style.backgroundColor = theme.palette.background.default; + }, [theme]); + + return ( + + + + {children} + + + ); +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..75d3b1e --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,290 @@ +import { CourseOut, CourseWithModules, ProgressOut, TokenPair, UserOut } from "./types"; + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +const ACCESS_KEY = "lomda_access"; +const REFRESH_KEY = "lomda_refresh"; +const ROLE_KEY = "lomda_role"; + +export function setTokens(tokens: TokenPair) { + localStorage.setItem(ACCESS_KEY, tokens.access_token); + localStorage.setItem(REFRESH_KEY, tokens.refresh_token); +} + +export function clearTokens() { + localStorage.removeItem(ACCESS_KEY); + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(ROLE_KEY); +} + +export function getAccessToken() { + return localStorage.getItem(ACCESS_KEY); +} + +export function getRefreshToken() { + return localStorage.getItem(REFRESH_KEY); +} + +export function setUserRole(role: string) { + localStorage.setItem(ROLE_KEY, role); +} + +export function getUserRole() { + return localStorage.getItem(ROLE_KEY); +} + +async function request(path: string, options: RequestInit = {}, retry = true): Promise { + const token = getAccessToken(); + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const res = await fetch(`${API_URL}${path}`, { ...options, headers }); + if (res.status === 401 && retry && getRefreshToken()) { + const refreshed = await refreshToken(); + if (refreshed) { + return request(path, options, false); + } + } + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.detail || "Request failed"); + } + return res.json(); +} + +export async function login(email: string, password: string): Promise { + const body = new URLSearchParams(); + body.set("username", email); + body.set("password", password); + const res = await fetch(`${API_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.detail || "Login failed"); + } + const data = (await res.json()) as TokenPair; + setTokens(data); + const me = await getMe(); + setUserRole(me.role); + return data; +} + +export async function register(email: string, password: string) { + return request("/auth/register", { + method: "POST", + body: JSON.stringify({ email, password }), + }); +} + +export async function getMe() { + return request("/auth/me"); +} + +export async function refreshToken() { + const refresh = getRefreshToken(); + if (!refresh) return false; + const res = await fetch(`${API_URL}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refresh }), + }); + if (!res.ok) return false; + const data = (await res.json()) as TokenPair; + setTokens(data); + return true; +} + +export async function listCourses() { + return request("/courses"); +} + +export async function enroll(courseId: number) { + return request(`/courses/${courseId}/enroll`, { method: "POST" }); +} + +export async function getCourse(courseId: number) { + return request(`/courses/${courseId}`); +} + +export async function completeContent(moduleId: number) { + return request(`/modules/${moduleId}/complete`, { method: "POST" }); +} + +export async function submitQuiz(moduleId: number, answers: Record) { + return request(`/modules/${moduleId}/quiz/submit`, { + method: "POST", + body: JSON.stringify({ answers }), + }); +} + +export async function getProgress() { + return request("/progress"); +} + +// Admin +export async function adminListCourses() { + return request("/admin/courses"); +} + +export async function adminCreateCourse(payload: Partial) { + return request("/admin/courses", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function adminUpdateCourse(id: number, payload: Partial) { + return request(`/admin/courses/${id}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export async function adminTogglePublish(id: number) { + return request(`/admin/courses/${id}/publish`, { method: "PATCH" }); +} + +export async function adminDeleteCourse(id: number) { + return request(`/admin/courses/${id}`, { method: "DELETE" }); +} + +export async function adminListModules(courseId: number) { + return request(`/admin/courses/${courseId}/modules`); +} + +export async function adminCreateModule(courseId: number, payload: any) { + return request(`/admin/courses/${courseId}/modules`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function adminUpdateModule(moduleId: number, payload: any) { + return request(`/admin/modules/${moduleId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export async function adminGetModule(moduleId: number) { + return request(`/admin/modules/${moduleId}`); +} + +export async function adminDeleteModule(moduleId: number) { + return request(`/admin/modules/${moduleId}`, { method: "DELETE" }); +} + +export async function adminListQuestions(moduleId: number) { + return request(`/admin/modules/${moduleId}/questions`); +} + +export async function adminCreateQuestion(moduleId: number, payload: any) { + return request(`/admin/modules/${moduleId}/questions`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function adminUpdateQuestion(questionId: number, payload: any) { + return request(`/admin/questions/${questionId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export async function adminDeleteQuestion(questionId: number) { + return request(`/admin/questions/${questionId}`, { method: "DELETE" }); +} + +export async function adminListUsers(query?: string) { + const suffix = query ? `?q=${encodeURIComponent(query)}` : ""; + return request(`/admin/users${suffix}`); +} + +export async function adminUpdateUser(userId: number, payload: any) { + return request(`/admin/users/${userId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function adminListGroups() { + return request(`/admin/groups`); +} + +export async function adminCreateGroup(payload: any) { + return request(`/admin/groups`, { method: "POST", body: JSON.stringify(payload) }); +} + +export async function adminUpdateGroup(groupId: number, payload: any) { + return request(`/admin/groups/${groupId}`, { method: "PUT", body: JSON.stringify(payload) }); +} + +export async function adminDeleteGroup(groupId: number) { + return request(`/admin/groups/${groupId}`, { method: "DELETE" }); +} + +export async function adminListGroupMembers(groupId: number) { + return request(`/admin/groups/${groupId}/members`); +} + +export async function adminAddGroupMember(groupId: number, payload: any) { + return request(`/admin/groups/${groupId}/members`, { method: "POST", body: JSON.stringify(payload) }); +} + +export async function adminRemoveGroupMember(groupId: number, userId: number) { + return request(`/admin/groups/${groupId}/members/${userId}`, { method: "DELETE" }); +} + +// Uploads +export async function adminUploadFile(file: File) { + const token = getAccessToken(); + const formData = new FormData(); + formData.append("file", file); + const res = await fetch(`${API_URL}/admin/uploads`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + if (!res.ok) { + throw new Error("Upload failed"); + } + return res.json(); +} + +export function getUploadUrl(uploadId: number) { + return `${API_URL}/uploads/${uploadId}`; +} + +// Achievements +export async function getMyAchievements() { + return request("/me/achievements"); +} + +// Admin stats +export async function adminStatsUsers() { + return request("/admin/stats/users"); +} + +export async function adminStatsUser(userId: number) { + return request(`/admin/stats/users/${userId}`); +} + +export async function adminStatsCourses() { + return request("/admin/stats/courses"); +} + +export async function adminStatsCourse(courseId: number) { + return request(`/admin/stats/courses/${courseId}`); +} + diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..e2237eb --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,69 @@ +export type TokenPair = { + access_token: string; + refresh_token: string; + token_type: string; +}; + +export type UserOut = { + id: number; + email: string; + role: "admin" | "learner"; +}; + +export type CourseOut = { + id: number; + title: string; + description?: string | null; + is_published: boolean; + created_at: string; + updated_at: string; +}; + +export type QuizChoice = { + id: number; + text: string; + is_correct?: boolean; +}; + +export type QuizQuestion = { + id: number; + prompt: string; + question_type: "mcq"; + choices: QuizChoice[]; +}; + +export type ModuleWithProgress = { + id: number; + order_index: number; + type: "content" | "quiz"; + title: string; + content_text?: string | null; + pass_score?: number | null; + upload_id?: number | null; + status: "locked" | "unlocked" | "completed"; + completed_at?: string | null; + score?: number | null; + questions: QuizQuestion[]; +}; + +export type CourseWithModules = { + id: number; + title: string; + description?: string | null; + is_published: boolean; + modules: ModuleWithProgress[]; +}; + +export type ProgressCourse = { + course_id: number; + title: string; + percent: number; + status: string; + final_exam_score?: number | null; + final_exam_passed?: boolean | null; + completed_at?: string | null; +}; + +export type ProgressOut = { + courses: ProgressCourse[]; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1e7a714 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import { ThemeProvider } from "./ThemeProvider"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); + diff --git a/frontend/src/pages/AchievementsPage.tsx b/frontend/src/pages/AchievementsPage.tsx new file mode 100644 index 0000000..3d81849 --- /dev/null +++ b/frontend/src/pages/AchievementsPage.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Box, Card, CardContent, Typography, Grid, Chip } from "@mui/material"; +import { EmojiEvents, Lock } from "@mui/icons-material"; +import { getMyAchievements } from "../api/client"; + +interface Achievement { + id: number; + code: string; + name: string; + description: string; + earned: boolean; + earned_at?: string; +} + +export default function AchievementsPage() { + const [achievements, setAchievements] = useState([]); + + useEffect(() => { + getMyAchievements().then((data) => setAchievements(data as Achievement[])); + }, []); + + const earned = achievements.filter((a: Achievement) => a.earned); + const locked = achievements.filter((a: Achievement) => !a.earned); + + return ( + + + Achievements + + + {earned.length} / {achievements.length} unlocked + + + + Earned + + + {earned.map((ach: Achievement) => ( + + + + + + {ach.name} + + + {ach.description} + + {ach.earned_at && ( + + )} + + + + ))} + + + + Locked + + + {locked.map((ach: Achievement) => ( + + + + + + {ach.name} + + + {ach.description} + + + + + ))} + + + ); +} diff --git a/frontend/src/pages/AdminCourseEditor.tsx b/frontend/src/pages/AdminCourseEditor.tsx new file mode 100644 index 0000000..ab5dee6 --- /dev/null +++ b/frontend/src/pages/AdminCourseEditor.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Chip, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { adminCreateCourse, adminUpdateCourse, adminListCourses, adminListGroups } from "../api/client"; + +export default function AdminCourseEditor() { + const { id } = useParams(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [isPublished, setIsPublished] = useState(false); + const [groups, setGroups] = useState([]); + const [groupIds, setGroupIds] = useState([]); + + useEffect(() => { + adminListGroups().then(setGroups); + }, []); + + useEffect(() => { + if (!isEdit) return; + adminListCourses().then((courses: any[]) => { + const course = courses.find((c) => c.id === Number(id)); + if (course) { + setTitle(course.title); + setDescription(course.description || ""); + setIsPublished(course.is_published); + setGroupIds(course.group_ids || []); + } + }); + }, [id, isEdit]); + + return ( + + + {isEdit ? "Edit course" : "New course"} + + setTitle(e.target.value)} /> + setDescription(e.target.value)} /> + + Visibility + + + + Allowed groups (optional) + + + + + + + ); +} diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx new file mode 100644 index 0000000..7109da5 --- /dev/null +++ b/frontend/src/pages/AdminDashboard.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { Box, Button, Card, CardContent, Stack, Typography } from "@mui/material"; +import { adminListCourses, adminTogglePublish, adminDeleteCourse } from "../api/client"; + +export default function AdminDashboard() { + const [courses, setCourses] = useState([]); + + const load = async () => { + const data = await adminListCourses(); + setCourses(data); + }; + + useEffect(() => { + load(); + }, []); + + return ( + + + Admin Dashboard + + + + + + + + {courses.map((course) => ( + + + {course.title} + {course.description} + + + + + + + + + + + ))} + + + ); +} diff --git a/frontend/src/pages/AdminGroupsPage.tsx b/frontend/src/pages/AdminGroupsPage.tsx new file mode 100644 index 0000000..6bf48ac --- /dev/null +++ b/frontend/src/pages/AdminGroupsPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { Box, Button, Card, CardContent, Divider, Stack, TextField, Typography } from "@mui/material"; +import { adminAddGroupMember, adminCreateGroup, adminDeleteGroup, adminListGroupMembers, adminListGroups, adminRemoveGroupMember } from "../api/client"; + +export default function AdminGroupsPage() { + const [groups, setGroups] = useState([]); + const [name, setName] = useState(""); + const [selectedGroup, setSelectedGroup] = useState(null); + const [members, setMembers] = useState([]); + const [memberEmail, setMemberEmail] = useState(""); + + const loadGroups = async () => { + const data = await adminListGroups(); + setGroups(data); + if (data.length && !selectedGroup) { + setSelectedGroup(data[0]); + } + }; + + const loadMembers = async (groupId: number) => { + const data = await adminListGroupMembers(groupId); + setMembers(data); + }; + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadMembers(selectedGroup.id); + } + }, [selectedGroup]); + + return ( + + + Groups + + setName(e.target.value)} /> + + + + + + {groups.map((group) => ( + setSelectedGroup(group)}> + + {group.name} + + + + ))} + + + {selectedGroup && ( + + + Members: {selectedGroup.name} + + + setMemberEmail(e.target.value)} /> + + + + {members.map((member) => ( + + {member.email} + + + ))} + + + + )} + + ); +} diff --git a/frontend/src/pages/AdminModuleEdit.tsx b/frontend/src/pages/AdminModuleEdit.tsx new file mode 100644 index 0000000..d4ba9ff --- /dev/null +++ b/frontend/src/pages/AdminModuleEdit.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Divider, + Stack, + TextField, + Typography, + Alert, +} from "@mui/material"; +import { adminGetModule, adminListQuestions, adminCreateQuestion, adminDeleteQuestion, adminUpdateModule, adminUploadFile, getUploadUrl } from "../api/client"; + +export default function AdminModuleEdit() { + const { id } = useParams(); + const moduleId = Number(id); + const navigate = useNavigate(); + const [module, setModule] = useState(null); + const [content, setContent] = useState(""); + const [passScore, setPassScore] = useState(80); + const [title, setTitle] = useState(""); + const [orderIndex, setOrderIndex] = useState(1); + const [uploadId, setUploadId] = useState(null); + const [questions, setQuestions] = useState([]); + const [uploadNotice, setUploadNotice] = useState(""); + + useEffect(() => { + adminGetModule(moduleId).then((data) => { + setModule(data); + setContent(data.content_text || ""); + setPassScore(data.pass_score || 80); + setTitle(data.title || ""); + setOrderIndex(data.order_index || 1); + setUploadId(data.upload_id || null); + }); + }, [moduleId]); + + useEffect(() => { + adminListQuestions(moduleId).then(setQuestions); + }, [moduleId]); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const result = await adminUploadFile(file); + setUploadId(result.id); + setUploadNotice(`Uploaded: ${result.filename}`); + } catch (err: any) { + setUploadNotice(`Upload failed: ${err.message}`); + } + }; + + if (!module) { + return Loading module...; + } + + return ( + + + Edit Module + + setTitle(e.target.value)} /> + setOrderIndex(Number(e.target.value))} /> + {module.type === "content" ? ( + <> + setContent(e.target.value)} /> + + Presentation (PDF/PPTX) + + {uploadNotice && {uploadNotice}} + {uploadId && ( + + Attached presentation (ID: {uploadId}) -{" "} + View + + )} + + + ) : ( + setPassScore(Number(e.target.value))} /> + )} + + {module.type === "quiz" && ( + + + Questions + + {questions.map((q) => ( + + + {q.prompt} + + {q.choices.map((c: any) => ( + + {c.text} {c.is_correct ? "(correct)" : ""} + + ))} + + + + + ))} + + + + )} + + + + + + ); +} diff --git a/frontend/src/pages/AdminModulesPage.tsx b/frontend/src/pages/AdminModulesPage.tsx new file mode 100644 index 0000000..8b8c213 --- /dev/null +++ b/frontend/src/pages/AdminModulesPage.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { Link as RouterLink, useParams } from "react-router-dom"; +import { Box, Button, Card, CardContent, MenuItem, Select, Stack, TextField, Typography } from "@mui/material"; +import { adminCreateModule, adminDeleteModule, adminListModules } from "../api/client"; + +export default function AdminModulesPage() { + const { id } = useParams(); + const courseId = Number(id); + const [modules, setModules] = useState([]); + const [type, setType] = useState<"content" | "quiz">("content"); + const [title, setTitle] = useState(""); + const [orderIndex, setOrderIndex] = useState(1); + + const load = async () => { + const data = await adminListModules(courseId); + setModules(data); + setOrderIndex(data.length + 1); + }; + + useEffect(() => { + load(); + }, [courseId]); + + return ( + + + Modules + + + + + Add module + + setTitle(e.target.value)} /> + + setOrderIndex(Number(e.target.value))} /> + + + + + + {modules.map((module) => ( + + + {module.order_index}. {module.title} + {module.type} + + + + + + + ))} + + + ); +} diff --git a/frontend/src/pages/AdminStatsPage.tsx b/frontend/src/pages/AdminStatsPage.tsx new file mode 100644 index 0000000..ce2945d --- /dev/null +++ b/frontend/src/pages/AdminStatsPage.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from "react"; +import { + Box, + Typography, + Tab, + Tabs, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Paper, + Button, + Dialog, + DialogTitle, + DialogContent, + List, + ListItem, + ListItemText, +} from "@mui/material"; +import { adminStatsUsers, adminStatsUser, adminStatsCourses, adminStatsCourse } from "../api/client"; + +interface UserStat { + id: number; + email: string; + enrollments_count: number; + completed_modules_count: number; + attempts_count: number; +} + +interface CourseStat { + id: number; + title: string; + enrollments_count: number; + completions_count: number; + final_exam_avg_score?: number; +} + +interface Attempt { + module_title: string; + score: number; + passed: boolean; + submitted_at: string; +} + +interface CourseDetail { + course_title: string; + progress_percent: number; + completed_modules: number; + total_modules: number; + attempts?: Attempt[]; +} + +interface UserDetail { + courses: CourseDetail[]; +} + +interface ModuleAttempt { + module_title: string; + attempts_count: number; +} + +interface CourseDetail2 { + attempts_distribution: ModuleAttempt[]; +} + +export default function AdminStatsPage() { + const [tab, setTab] = useState(0); + const [usersStats, setUsersStats] = useState([]); + const [coursesStats, setCoursesStats] = useState([]); + const [detailOpen, setDetailOpen] = useState(false); + const [detailData, setDetailData] = useState(null); + + useEffect(() => { + adminStatsUsers().then((data) => setUsersStats(data as UserStat[])); + adminStatsCourses().then((data) => setCoursesStats(data as CourseStat[])); + }, []); + + const viewUserDetail = async (userId: number) => { + const data = await adminStatsUser(userId); + setDetailData(data as UserDetail); + setDetailOpen(true); + }; + + const viewCourseDetail = async (courseId: number) => { + const data = await adminStatsCourse(courseId); + setDetailData(data as CourseDetail2); + setDetailOpen(true); + }; + + return ( + + + Analytics + + + setTab(v)} sx={{ mb: 3 }}> + + + + + {tab === 0 && ( + + + + + Email + Enrollments + Completed Modules + Attempts + Actions + + + + {usersStats.map((user: UserStat) => ( + + {user.email} + {user.enrollments_count} + {user.completed_modules_count} + {user.attempts_count} + + + + + ))} + +
+
+ )} + + {tab === 1 && ( + + + + + Title + Enrollments + Completions + Final Avg Score + Actions + + + + {coursesStats.map((course: CourseStat) => ( + + {course.title} + {course.enrollments_count} + {course.completions_count} + {course.final_exam_avg_score?.toFixed(1) || "N/A"} + + + + + ))} + +
+
+ )} + + setDetailOpen(false)} maxWidth="md" fullWidth> + Details + + {detailData && 'courses' in detailData && ( + + {detailData.courses.map((course: CourseDetail, idx: number) => ( + + + Progress: {course.progress_percent.toFixed(1)}% | Modules: {course.completed_modules}/ + {course.total_modules} + {course.attempts && course.attempts.length > 0 && ( + + Attempts: + {course.attempts.map((att: Attempt, i: number) => ( + + {att.module_title}: {att.score} ({att.passed ? "passed" : "failed"}) -{" "} + {new Date(att.submitted_at).toLocaleString()} + + ))} + + )} + + } + /> + + ))} + + )} + {detailData && 'attempts_distribution' in detailData && ( + + {detailData.attempts_distribution.map((mod: ModuleAttempt, idx: number) => ( + + + + ))} + + )} + + +
+ ); +} diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..ca319f4 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -0,0 +1,60 @@ +import { useEffect, useMemo, useState } from "react"; +import { Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material"; +import { adminListUsers, adminUpdateUser } from "../api/client"; + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [query, setQuery] = useState(""); + + const load = async (q?: string) => { + const data = await adminListUsers(q); + setUsers(data); + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => users, [users]); + + return ( + + + Users + + setQuery(e.target.value)} /> + + + + + {filtered.map((user) => ( + + + + + {user.email} + Role: {user.role} + Active: {user.is_active ? "Yes" : "No"} + + + + + + + + + ))} + + + ); +} diff --git a/frontend/src/pages/CoursePlayerPage.tsx b/frontend/src/pages/CoursePlayerPage.tsx new file mode 100644 index 0000000..4c67b88 --- /dev/null +++ b/frontend/src/pages/CoursePlayerPage.tsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + Box, + Card, + CardContent, + Chip, + Divider, + LinearProgress, + Stack, + Typography, + Button, +} from "@mui/material"; +import LockIcon from "@mui/icons-material/Lock"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import { completeContent, getCourse, submitQuiz, getUploadUrl } from "../api/client"; +import { CourseWithModules, ModuleWithProgress } from "../api/types"; + +export default function CoursePlayerPage() { + const { id } = useParams(); + const courseId = Number(id); + const [course, setCourse] = useState(null); + const [selected, setSelected] = useState(null); + const [notice, setNotice] = useState(null); + + useEffect(() => { + getCourse(courseId) + .then((data) => { + setCourse(data); + setSelected(data.modules[0]); + }) + .catch((err) => setNotice(err.message)); + }, [courseId]); + + const progressPercent = useMemo(() => { + if (!course) return 0; + const completed = course.modules.filter((m) => m.status === "completed").length; + return (completed / course.modules.length) * 100; + }, [course]); + + const courseCompleted = useMemo(() => { + if (!course) return false; + const allCompleted = course.modules.every((m) => m.status === "completed"); + if (!allCompleted) return false; + const final = course.modules[course.modules.length - 1]; + if (final.type === "quiz") { + const pass = (final.score || 0) >= (final.pass_score || 80); + return pass; + } + return true; + }, [course]); + + const isFinalExam = useMemo(() => { + if (!course || !selected) return false; + const last = course.modules[course.modules.length - 1]; + return last.id === selected.id && selected.type === "quiz"; + }, [course, selected]); + + if (!course) { + return {notice || "Loading..."}; + } + + return ( + + + + + {course.title} + {course.description} + + + {Math.round(progressPercent)}% complete + {courseCompleted && } + + + + + + {courseCompleted && ( + + + Course Completed + You have completed the final exam and finished this course. + + + )} + + + + + Modules + + {course.modules.map((module) => ( + { + if (module.status === "locked") { + setNotice("Complete previous module to unlock"); + return; + } + setNotice(null); + setSelected(module); + }} + > + + + {module.order_index}. {module.title} + {module.type === "quiz" && ( + + )} + + {module.status === "locked" ? : module.status === "completed" ? : } + + + ))} + + + + + + + {notice && {notice}} + {selected && ( + + {selected.title} + {isFinalExam && } + + {selected.type === "content" ? ( + <> + {selected.content_text} + {selected.upload_id && ( + + )} + + + ) : ( + { + const result = await submitQuiz(selected.id, answers); + const data = await getCourse(courseId); + setCourse(data); + const updated = data.modules.find((m) => m.id === selected.id) || null; + setSelected(updated); + setNotice(`Score: ${result.score.toFixed(1)}% (${result.passed ? "Passed" : "Failed"})`); + }} /> + )} + + )} + + + + + ); +} + +function QuizModule({ module, onSubmit }: { module: ModuleWithProgress; onSubmit: (answers: Record) => Promise; }) { + const [answers, setAnswers] = useState>({}); + + return ( + + + {module.questions.map((q) => ( + + {q.prompt} + + {q.choices.map((choice) => ( + + ))} + + + ))} + + {module.pass_score && Pass score: {module.pass_score}%} + + + ); +} diff --git a/frontend/src/pages/CoursesPage.tsx b/frontend/src/pages/CoursesPage.tsx new file mode 100644 index 0000000..1f26a5e --- /dev/null +++ b/frontend/src/pages/CoursesPage.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { Box, Button, Card, CardContent, Chip, LinearProgress, Stack, Typography } from "@mui/material"; +import { listCourses, enroll, getProgress } from "../api/client"; +import { CourseOut, ProgressOut } from "../api/types"; + +export default function CoursesPage() { + const [courses, setCourses] = useState([]); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + listCourses().then(setCourses).catch((err) => setError(err.message)); + getProgress().then(setProgress).catch(() => null); + }, []); + + const progressMap = useMemo(() => { + const map: Record = {}; + progress?.courses.forEach((c) => { + map[c.course_id] = { percent: c.percent, status: c.status }; + }); + return map; + }, [progress]); + + return ( + + + Courses + {progress && } + + {error && {error}} + + {courses.map((course) => { + const info = progressMap[course.id] || { percent: 0, status: "" }; + const isCompleted = info.percent >= 100 || info.status === "passed"; + return ( + + + + {course.title} + {course.description} + + + {Math.round(info.percent)}% complete + {isCompleted && } + + + + + + + + + ); + })} + + + ); +} diff --git a/frontend/src/pages/GoogleCallbackPage.tsx b/frontend/src/pages/GoogleCallbackPage.tsx new file mode 100644 index 0000000..cca4d72 --- /dev/null +++ b/frontend/src/pages/GoogleCallbackPage.tsx @@ -0,0 +1,48 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { setTokens, setUserRole, getMe } from "../api/client"; +import { Box, CircularProgress, Typography } from "@mui/material"; + +export default function GoogleCallbackPage() { + const navigate = useNavigate(); + + useEffect(() => { + const handleCallback = async () => { + console.log("Full URL:", window.location.href); + const params = new URLSearchParams(window.location.search); + const access = params.get("access_token"); + const refresh = params.get("refresh_token"); + + console.log("Access token present:", !!access); + console.log("Refresh token present:", !!refresh); + + if (access && refresh) { + try { + console.log("Setting tokens..."); + setTokens({ access_token: access, refresh_token: refresh, token_type: "bearer" }); + console.log("Fetching user info..."); + const me = await getMe(); + console.log("User info received:", me); + setUserRole(me.role); + console.log("Navigating to /courses"); + navigate("/courses", { replace: true }); + } catch (error) { + console.error("OAuth callback error:", error); + navigate("/login", { replace: true }); + } + } else { + console.error("Missing tokens in URL params"); + navigate("/login", { replace: true }); + } + }; + + handleCallback(); + }, [navigate]); + + return ( + + + Signing you in... + + ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..c51221d --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Button, Card, CardContent, Divider, Stack, TextField, Typography } from "@mui/material"; +import { login } from "../api/client"; + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + return ( + + + + Welcome back + {error && {error}} + + setEmail(e.target.value)} /> + setPassword(e.target.value)} /> + + + + + + + + ); +} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..4a7d36d --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material"; +import { register } from "../api/client"; + +export default function RegisterPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + return ( + + + + Create an account + {error && {error}} + + setEmail(e.target.value)} /> + setPassword(e.target.value)} /> + + + + + + ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..4dac818 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,14 @@ +:root { + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + color-scheme: light; +} + +body { + margin: 0; + background: #f6f8fb; + color: #1f2933; +} + +#root { + min-height: 100vh; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f791ee5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3f2919a --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + }, +});