From 3e34d84a29b484c55f3414610339b2d974a15e35 Mon Sep 17 00:00:00 2001 From: Space <64922620+Space-Banane@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:37:57 +0100 Subject: [PATCH] first commit --- .dockerignore | 4 + .env.example | 26 + .github/copilot-instructions.md | 140 ++ .gitignore | 20 + README.md | 237 ++ app/api/client.ts | 286 +++ app/app.css | 1 + app/components/Button.tsx | 40 + app/components/GradeCalculator.tsx | 221 ++ app/components/GradeTable.tsx | 149 ++ app/components/GradingCategorySelector.tsx | 142 ++ app/components/Input.tsx | 25 + app/components/LoadingSpinner.tsx | 19 + app/components/ProtectedRoute.tsx | 23 + app/components/StatCard.tsx | 30 + app/components/SubjectCard.tsx | 114 + app/root.tsx | 75 + app/routes.ts | 13 + app/routes/dashboard.tsx | 629 +++++ app/routes/home.tsx | 18 + app/routes/login.tsx | 78 + app/routes/periods.tsx | 256 ++ app/routes/register.tsx | 113 + app/routes/subjects.$subjectId.edit.tsx | 305 +++ app/routes/subjects.$subjectId.tsx | 486 ++++ app/routes/subjects.new.tsx | 206 ++ app/routes/subjects.tsx | 110 + app/types/api.ts | 149 ++ app/utils/gradeCalculations.ts | 431 ++++ app/utils/gradeSystems.ts | 179 ++ backend/auth.py | 38 + backend/config.py | 23 + backend/database.py | 26 + backend/main.py | 168 ++ backend/models.py | 265 ++ backend/requirements.txt | 10 + backend/routes/__init__.py | 7 + backend/routes/auth_routes.py | 149 ++ backend/routes/grade_routes.py | 202 ++ backend/routes/period_routes.py | 149 ++ backend/routes/subject_routes.py | 152 ++ backend/routes/teacher_grade_routes.py | 186 ++ docker-compose.yml | 40 + package.json | 30 + pnpm-lock.yaml | 2567 ++++++++++++++++++++ public/favicon.ico | Bin 0 -> 15086 bytes react-router.config.ts | 7 + tsconfig.json | 27 + vite.config.ts | 8 + 49 files changed, 8579 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/api/client.ts create mode 100644 app/app.css create mode 100644 app/components/Button.tsx create mode 100644 app/components/GradeCalculator.tsx create mode 100644 app/components/GradeTable.tsx create mode 100644 app/components/GradingCategorySelector.tsx create mode 100644 app/components/Input.tsx create mode 100644 app/components/LoadingSpinner.tsx create mode 100644 app/components/ProtectedRoute.tsx create mode 100644 app/components/StatCard.tsx create mode 100644 app/components/SubjectCard.tsx create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/dashboard.tsx create mode 100644 app/routes/home.tsx create mode 100644 app/routes/login.tsx create mode 100644 app/routes/periods.tsx create mode 100644 app/routes/register.tsx create mode 100644 app/routes/subjects.$subjectId.edit.tsx create mode 100644 app/routes/subjects.$subjectId.tsx create mode 100644 app/routes/subjects.new.tsx create mode 100644 app/routes/subjects.tsx create mode 100644 app/types/api.ts create mode 100644 app/utils/gradeCalculations.ts create mode 100644 app/utils/gradeSystems.ts create mode 100644 backend/auth.py create mode 100644 backend/config.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/auth_routes.py create mode 100644 backend/routes/grade_routes.py create mode 100644 backend/routes/period_routes.py create mode 100644 backend/routes/subject_routes.py create mode 100644 backend/routes/teacher_grade_routes.py create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 public/favicon.ico create mode 100644 react-router.config.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4d48318 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Frontend environment variables +VITE_API_URL=http://localhost:8000/api + +# Backend environment variables + +# MongoDB connection string +# For local development: mongodb://localhost:27017 +# For Docker: mongodb://mongodb:27017 +# For remote MongoDB: mongodb://username:password@host:port +MONGODB_URL=mongodb://mongodb:27017 + +# Database name +DATABASE_NAME=grademaxxing + +# JWT Secret Key - CHANGE THIS IN PRODUCTION! +# Generate a secure key with: python -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY=your-super-secret-key-change-this-in-production + +# JWT Algorithm +ALGORITHM=HS256 + +# Access token expiration in minutes (default: 43200 = 30 days) +ACCESS_TOKEN_EXPIRE_MINUTES=43200 + +# CORS allowed origins (comma-separated) +CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://127.0.0.1:8000 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..bdbe107 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,140 @@ +# Grademaxxing - AI Coding Agent Instructions + +## Project Architecture + +**Fullstack SPA Architecture:** +- **Frontend:** React Router 7 SPA (client-side only, no SSR) built with Vite + TypeScript + Tailwind CSS v4 +- **Backend:** FastAPI (Python) serves both API (`/api/*`) and built React SPA (`build/client/`) +- **Database:** MongoDB with Motor async driver +- **Deployment:** Docker Compose with 3 services (mongodb, frontend-build, backend) + +**Critical Architectural Decision:** Backend serves the compiled React SPA from `build/client/` directory. All non-`/api/*` routes fall through to `index.html` for client-side routing. Frontend must be built before backend serves it. + +## Essential Workflows + +**Development:** +```bash +# Docker (recommended) - starts MongoDB + builds frontend + runs backend +docker-compose up --build + +# Local development (3 terminals) +# Terminal 1: cd backend && uvicorn main:app --reload +# Terminal 2: pnpm dev +# Terminal 3: mongod --dbpath ./data +``` + +**Build & Deploy:** +```bash +pnpm build # Builds React SPA to build/client/ +# Backend automatically serves from build/client/ when running +``` + +**Quick start scripts:** `start.bat` (Windows) or `./start.sh` (Linux/Mac) wrap `docker-compose up --build` + +## Data Model & Business Logic + +**Flexible Grading System (Not German-specific):** +- Each `Subject` has custom `grading_categories` (e.g., "Written" 60%, "Oral" 40%) +- Categories must sum to 100% weight (validated in [backend/models.py](backend/models.py#L74-L80)) +- Each `Grade` has `grade/max_grade` (e.g., 85/100) and `weight_in_category` for multiple tests +- Supports any grading scale (percentage, German 1-6, US letters, etc.) + +**Grade Calculation Pipeline ([app/utils/gradeCalculations.ts](app/utils/gradeCalculations.ts)):** +1. Normalize each grade to percentage: `(grade / max_grade) * 100` +2. Calculate category average: weighted sum of grades in category +3. Calculate subject average: weighted sum of category averages +4. Calculate overall GPA: simple average of all subject averages + +**Report Periods:** Filter grades by date range for semester/quarter calculations (see [calculateReportCard](app/utils/gradeCalculations.ts#L80-L110)) + +## Code Conventions + +**Backend (FastAPI + MongoDB):** +- All routes require auth via `Depends(get_current_user)` from [backend/routes/auth_routes.py](backend/routes/auth_routes.py) +- MongoDB ObjectId handling: Convert to string for JSON responses, convert to `ObjectId()` for queries +- Model pattern: `{Entity}Base` → `{Entity}Create/Update` → `{Entity}InDB` → `{Entity}Response` (see [backend/models.py](backend/models.py)) +- Route structure: Each entity gets its own router in `backend/routes/`, imported in [main.py](backend/main.py#L40-L43) +- Always use `await` with Motor async operations: `await db.collection.find().to_list()` + +**Frontend (React Router 7 SPA):** +- Route definitions in [app/routes.ts](app/routes.ts), files in `app/routes/` (use `.tsx` extension) +- All authenticated pages wrap children in `` component ([app/components/ProtectedRoute.tsx](app/components/ProtectedRoute.tsx)) +- API client singleton: `import { api } from "~/api/client"` - handles auth headers automatically +- Auth token stored in localStorage (`access_token`), set on login/register, removed on logout +- Component pattern: Small reusable components in `app/components/`, import as `~/components/{Name}` +- Tailwind CSS only - no CSS modules or styled-components + +**TypeScript Type Safety:** +- Backend types defined in [backend/models.py](backend/models.py) (Pydantic) +- Frontend types in [app/types/api.ts](app/types/api.ts) - **manually sync with backend models** +- Use `_id` (alias) for MongoDB ObjectId in responses, map to `id` field in TypeScript when needed + +## Authentication & Authorization + +**Flow:** +1. Login/register → Backend returns JWT + user data ([backend/routes/auth_routes.py](backend/routes/auth_routes.py)) +2. Frontend stores token in localStorage ([app/api/client.ts](app/api/client.ts#L56-L58)) +3. All protected API calls include `Authorization: Bearer {token}` header +4. Backend validates token with `get_current_user` dependency +5. Frontend uses `ProtectedRoute` wrapper to redirect unauthenticated users + +**User Isolation:** All database queries filter by `user_id` (from token) - users only see their own data + +## Common Patterns + +**Adding a New Entity:** +1. Define Pydantic models in [backend/models.py](backend/models.py): `{Entity}Base`, `{Entity}Create`, `{Entity}InDB`, `{Entity}Response` +2. Create router in `backend/routes/{entity}_routes.py` with CRUD endpoints +3. Import and include router in [backend/main.py](backend/main.py) +4. Add TypeScript types to [app/types/api.ts](app/types/api.ts) +5. Add API client methods to [app/api/client.ts](app/api/client.ts) +6. Create routes/components in `app/` + +**Adding a New Route:** +1. Create `app/routes/{name}.tsx` with default export component +2. Add route to [app/routes.ts](app/routes.ts) config array +3. Wrap in `` if auth required + +**MongoDB Query Pattern:** +```python +db = get_database() +cursor = db.collection.find({"user_id": current_user.id}) +items = await cursor.to_list(length=None) +for item in items: + item["_id"] = str(item["_id"]) # Convert ObjectId to string +return [ResponseModel(**item) for item in items] +``` + +## Critical Files + +- [backend/main.py](backend/main.py) - FastAPI app initialization, SPA serving, route inclusion +- [app/api/client.ts](app/api/client.ts) - Centralized API client with auth +- [app/utils/gradeCalculations.ts](app/utils/gradeCalculations.ts) - Core business logic for grade calculations +- [backend/models.py](backend/models.py) - Pydantic models with validation (category weights must sum to 100%) +- [docker-compose.yml](docker-compose.yml) - Multi-stage build: frontend-build → backend serves result + +## Known Issues & Gotchas + +- Frontend build must complete before backend serves (`frontend-build` container in Docker) +- MongoDB ObjectId serialization: Always convert to string for JSON responses +- CORS: Backend allows origins from `CORS_ORIGINS` env var (comma-separated) +- Category weights validation: Must sum to 100% (enforced in Pydantic model) +- Date handling: Python uses `datetime.utcnow()`, frontend converts ISO strings +- No SSR: React Router in SPA mode only, backend serves static build + +## Testing + +**Manual testing:** See [DEVELOPMENT.md](DEVELOPMENT.md#L244-L254) checklist (register → create period → add subject → add grades → view dashboard) + +**API Testing:** FastAPI auto-docs at `http://localhost:8000/docs` (Swagger UI) + +## Environment Setup + +**Required Environment Variables:** +- `MONGODB_URL` - MongoDB connection string (default: `mongodb://localhost:27017`) +- `SECRET_KEY` - JWT signing key (generate with `python -c "import secrets; print(secrets.token_urlsafe(64))"`) +- `DATABASE_NAME` - MongoDB database name (default: `grademaxxing`) +- `CORS_ORIGINS` - Comma-separated allowed origins (default: `http://localhost:5173,http://localhost:8000`) +- `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT expiration (default: 10080 = 7 days) + +Frontend uses `VITE_API_URL` for API base URL (defaults to `http://localhost:8000/api`) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c900604 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ + +# Python +__pycache__/ +*.pyc +.env + +# Docker +/docker-compose.override.yml +*.log + +/db + +.pnpm-store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a8433e --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# Grademaxxing - Grade Management System + +A full-stack web application for managing grades with flexible grading systems, supporting multiple users, custom grading categories, and report periods. + +## Features + +- 🔐 **Multi-user authentication** with JWT tokens +- 📚 **Flexible subject management** with custom grading categories (not limited to German system) +- 📊 **Grade tracking** with weighted categories +- 📅 **Report periods** for semester/quarter grades +- 📈 **Automatic calculations** for category averages and overall GPAs +- 🎨 **Modern UI** with Tailwind CSS +- 🐳 **Fully containerized** with Docker Compose + +## Tech Stack + +**Frontend:** +- React Router 7 (SPA mode) +- TypeScript +- Tailwind CSS v4 +- Vite + +**Backend:** +- FastAPI (Python) +- MongoDB (with Motor async driver) +- JWT authentication +- Pydantic validation + +## Project Structure + +``` +grademaxxing/ +├── app/ # Frontend React application +│ ├── api/ # API client +│ ├── components/ # Reusable UI components +│ ├── routes/ # Page routes +│ ├── types/ # TypeScript types +│ └── utils/ # Utility functions +├── backend/ # Python FastAPI backend +│ ├── routes/ # API endpoints +│ ├── main.py # FastAPI application +│ ├── models.py # Pydantic models +│ ├── database.py # MongoDB connection +│ ├── auth.py # Authentication utilities +│ ├── config.py # Configuration +│ └── requirements.txt # Python dependencies +├── docker-compose.yml # Docker Compose configuration +├── Dockerfile.backend # Backend container +└── Dockerfile # Frontend container (optional) +``` + +## Quick Start with Docker + +### Prerequisites +- Docker +- Docker Compose + +### Setup + +1. **Clone the repository** + ```bash + git clone + cd grademaxxing + ``` + +2. **Create environment file** + ```bash + cp .env.example .env + ``` + + Edit `.env` and configure the following required variables: + + ```env + # MongoDB connection string (for Docker use: mongodb://mongodb:27017) + MONGODB_URL=mongodb://mongodb:27017 + + # Database name + DATABASE_NAME=grademaxxing + + # JWT Secret Key - GENERATE A SECURE KEY! + # Run: python -c "import secrets; print(secrets.token_urlsafe(64))" + SECRET_KEY=your-super-secret-key-change-this-in-production + + # Token expiration (30 days = 43200 minutes) + ACCESS_TOKEN_EXPIRE_MINUTES=43200 + + # CORS origins + CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://127.0.0.1:8000 + ``` + +3. **Start all services** + ```bash + docker-compose up --build + ``` + + This will: + - Start MongoDB on port 27017 + - Build the frontend + - Start the Python backend on port 8000 + - Serve the frontend through the backend + +4. **Access the application** + - Open your browser and navigate to: `http://localhost:8000` + - Create an account and start managing your grades! + +### Docker Services + +- **mongodb**: MongoDB database (port 27017) +- **frontend-build**: Builds the React SPA (runs once) +- **backend**: FastAPI server serving API and static files (port 8000) + +### Stopping Services + +```bash +# Stop all services +docker-compose down + +# Stop and remove volumes (clears database) +docker-compose down -v +``` + +## Development + +For local development without Docker, see [DEVELOPMENT.md](DEVELOPMENT.md). + +## Environment Variables + +All configuration is managed through the `.env` file in the project root. + +**Required variables:** + +| Variable | Description | Example | +|----------|-------------|---------| +| `MONGODB_URL` | MongoDB connection string | `mongodb://mongodb:27017` (Docker)
`mongodb://localhost:27017` (Local) | +| `DATABASE_NAME` | Database name | `grademaxxing` | +| `SECRET_KEY` | JWT signing key (keep secure!) | Generate with: `python -c "import secrets; print(secrets.token_urlsafe(64))"` | +| `ALGORITHM` | JWT algorithm | `HS256` | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | Token expiration time | `43200` (30 days) | +| `CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:5173,http://localhost:8000` | +| `VITE_API_URL` | Frontend API endpoint | `http://localhost:8000/api` | + +**Note:** The `.env.example` file contains all available variables with documentation. + +## Usage Guide + +### 1. Create an Account +- Register with email, username, and password +- Login to access your dashboard + +### 2. Create Report Periods +- Go to "Report Periods" +- Define time periods (e.g., "First Semester 2024/2025") +- Set start and end dates + +### 3. Add Subjects +- Click "Add Subject" +- Enter subject name and optional teacher +- Define grading categories with weights (must sum to 100%) + - Example: "Written" (60%), "Oral" (40%) + - Or: "Tests" (70%), "Homework" (20%), "Participation" (10%) +- Choose a color for visual identification + +### 4. Add Grades +- Click on a subject +- Add grades with: + - Name (optional) + - Category + - Grade value and maximum + - Weight within category + - Date + - Notes (optional) + +### 5. View Report Cards +- Dashboard shows overall average for selected period +- Report card table displays category and final grades per subject +- Automatic weighted average calculations + +## API Documentation + +Once the backend is running, visit: +- API docs: `http://localhost:8000/docs` +- Alternative docs: `http://localhost:8000/redoc` + +### Main Endpoints + +**Authentication:** +- `POST /api/auth/register` - Create account +- `POST /api/auth/login` - Login +- `GET /api/auth/me` - Get current user + +**Subjects:** +- `GET /api/subjects` - List subjects +- `POST /api/subjects` - Create subject +- `PUT /api/subjects/{id}` - Update subject +- `DELETE /api/subjects/{id}` - Delete subject + +**Grades:** +- `GET /api/grades?subject_id={id}` - List grades +- `POST /api/grades` - Create grade +- `PUT /api/grades/{id}` - Update grade +- `DELETE /api/grades/{id}` - Delete grade + +**Report Periods:** +- `GET /api/periods` - List periods +- `POST /api/periods` - Create period +- `PUT /api/periods/{id}` - Update period +- `DELETE /api/periods/{id}` - Delete period + +## Building for Production + +### Using Docker Compose +```bash +docker-compose up --build -d +``` + +### Manual Build + +**Frontend:** +```bash +pnpm build +``` +Output: `build/client/` + +**Backend:** +```bash +cd backend +pip install -r requirements.txt +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +## License + +MIT + +## Contributing + +Contributions welcome! Please open an issue or submit a pull request. diff --git a/app/api/client.ts b/app/api/client.ts new file mode 100644 index 0000000..1aad828 --- /dev/null +++ b/app/api/client.ts @@ -0,0 +1,286 @@ +import type { + AuthResponse, + UserLogin, + UserRegister, + User, + Subject, + SubjectCreate, + SubjectUpdate, + Grade, + GradeCreate, + GradeUpdate, + ReportPeriod, + ReportPeriodCreate, + ReportPeriodUpdate, + TeacherGrade, + TeacherGradeCreate, + TeacherGradeUpdate, +} from "~/types/api"; + +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; + +class ApiClient { + private getHeaders(includeAuth: boolean = false): HeadersInit { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (includeAuth) { + const token = localStorage.getItem("access_token"); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + } + + return headers; + } + + private async handleResponse(response: Response): Promise { + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "An error occurred" })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + // Auth endpoints + async register(data: UserRegister): Promise { + const response = await fetch(`${API_BASE_URL}/auth/register`, { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify(data), + }); + const result = await this.handleResponse(response); + localStorage.setItem("access_token", result.access_token); + return result; + } + + async login(data: UserLogin): Promise { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify(data), + }); + const result = await this.handleResponse(response); + localStorage.setItem("access_token", result.access_token); + return result; + } + + async getMe(): Promise { + const response = await fetch(`${API_BASE_URL}/auth/me`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + logout(): void { + localStorage.removeItem("access_token"); + } + + isAuthenticated(): boolean { + return !!localStorage.getItem("access_token"); + } + + // Subject endpoints + async getSubjects(): Promise { + const response = await fetch(`${API_BASE_URL}/subjects`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async getSubject(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/subjects/${id}`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async createSubject(data: SubjectCreate): Promise { + const response = await fetch(`${API_BASE_URL}/subjects`, { + method: "POST", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async updateSubject(id: string, data: SubjectUpdate): Promise { + const response = await fetch(`${API_BASE_URL}/subjects/${id}`, { + method: "PUT", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async deleteSubject(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/subjects/${id}`, { + method: "DELETE", + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + // Grade endpoints + async getGrades(subjectId?: string): Promise { + const url = subjectId + ? `${API_BASE_URL}/grades?subject_id=${subjectId}` + : `${API_BASE_URL}/grades`; + const response = await fetch(url, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async getGrade(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/grades/${id}`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async createGrade(data: GradeCreate): Promise { + const response = await fetch(`${API_BASE_URL}/grades`, { + method: "POST", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async updateGrade(id: string, data: GradeUpdate): Promise { + const response = await fetch(`${API_BASE_URL}/grades/${id}`, { + method: "PUT", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async deleteGrade(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/grades/${id}`, { + method: "DELETE", + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + // Report Period endpoints + async getPeriods(): Promise { + const response = await fetch(`${API_BASE_URL}/periods`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async getPeriod(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/periods/${id}`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async createPeriod(data: ReportPeriodCreate): Promise { + const response = await fetch(`${API_BASE_URL}/periods`, { + method: "POST", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async updatePeriod(id: string, data: ReportPeriodUpdate): Promise { + const response = await fetch(`${API_BASE_URL}/periods/${id}`, { + method: "PUT", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async deletePeriod(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/periods/${id}`, { + method: "DELETE", + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + // Teacher Grade endpoints + async getTeacherGrades(subjectId?: string, periodId?: string): Promise { + const params = new URLSearchParams(); + if (subjectId) params.append("subject_id", subjectId); + if (periodId) params.append("period_id", periodId); + const url = `${API_BASE_URL}/teacher-grades${params.toString() ? `?${params.toString()}` : ""}`; + + const response = await fetch(url, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async getTeacherGrade(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/teacher-grades/${id}`, { + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + async createTeacherGrade(data: TeacherGradeCreate): Promise { + const response = await fetch(`${API_BASE_URL}/teacher-grades`, { + method: "POST", + headers: this.getHeaders(true), + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + async deleteTeacherGrade(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/teacher-grades/${id}`, { + method: "DELETE", + headers: this.getHeaders(true), + }); + return this.handleResponse(response); + } + + // Export endpoints + async exportGradesCSV(periodId?: string): Promise { + const url = periodId + ? `${API_BASE_URL}/export/grades?period_id=${periodId}` + : `${API_BASE_URL}/export/grades`; + + const response = await fetch(url, { + headers: this.getHeaders(true), + }); + + if (!response.ok) { + throw new Error("Failed to export grades"); + } + + // Create a blob from the response and trigger download + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = downloadUrl; + + // Extract filename from Content-Disposition header or use default + const contentDisposition = response.headers.get("Content-Disposition"); + const filename = contentDisposition + ? contentDisposition.split("filename=")[1]?.replace(/"/g, "") + : "grades_export.csv"; + + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(downloadUrl); + } +} + +export const api = new ApiClient(); diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/app/app.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/app/components/Button.tsx b/app/components/Button.tsx new file mode 100644 index 0000000..934e024 --- /dev/null +++ b/app/components/Button.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "danger" | "ghost"; + size?: "sm" | "md" | "lg"; + children: ReactNode; +} + +export function Button({ + variant = "primary", + size = "md", + className = "", + children, + ...props +}: ButtonProps) { + const baseStyles = + "font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"; + + const variants = { + primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800", + secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 active:bg-gray-400", + danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800", + ghost: "text-gray-700 hover:bg-gray-100 active:bg-gray-200", + }; + + const sizes = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2", + lg: "px-6 py-3 text-lg", + }; + + return ( + + ); +} diff --git a/app/components/GradeCalculator.tsx b/app/components/GradeCalculator.tsx new file mode 100644 index 0000000..a214d2e --- /dev/null +++ b/app/components/GradeCalculator.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import type { Subject, Grade } from "~/types/api"; +import { calculateRequiredGrade, type RequiredGradeResult } from "~/utils/gradeCalculations"; +import { Button } from "./Button"; +import { Input } from "./Input"; + +interface GradeCalculatorProps { + subject: Subject; + currentGrades: Grade[]; +} + +export function GradeCalculator({ subject, currentGrades }: GradeCalculatorProps) { + const [targetGrade, setTargetGrade] = useState( + subject.target_grade?.toString() || "" + ); + const [maxGradeValue, setMaxGradeValue] = useState( + subject.grade_system === "german" ? "6" : "100" + ); + const [remainingAssignments, setRemainingAssignments] = useState<{ + [key: string]: string; + }>(() => { + const initial: { [key: string]: string } = {}; + subject.grading_categories.forEach((cat) => { + initial[cat.name] = "1"; + }); + return initial; + }); + const [results, setResults] = useState(null); + const [showCalculator, setShowCalculator] = useState(false); + + const handleCalculate = () => { + if (!targetGrade || parseFloat(targetGrade) <= 0) { + alert("Please enter a valid target grade"); + return; + } + + const maxGrade = parseFloat(maxGradeValue); + const target = parseFloat(targetGrade); + + // Convert target to percentage + let targetPercentage: number; + if (subject.grade_system === "german" && maxGrade === 6) { + // For German grades, convert using rough approximation + if (target <= 1.5) targetPercentage = 92; + else if (target <= 2.5) targetPercentage = 81; + else if (target <= 3.5) targetPercentage = 67; + else if (target <= 4.0) targetPercentage = 50; + else if (target <= 5.0) targetPercentage = 30; + else targetPercentage = 0; + } else { + targetPercentage = (target / maxGrade) * 100; + } + + // Convert remaining assignments to numbers + const remaining: { [key: string]: number } = {}; + Object.keys(remainingAssignments).forEach((key) => { + remaining[key] = parseInt(remainingAssignments[key]) || 0; + }); + + const calculationResults = calculateRequiredGrade( + subject, + currentGrades, + targetPercentage, + remaining, + maxGrade + ); + + setResults(calculationResults); + }; + + if (!showCalculator) { + return ( +
+
+
+

+ 🎯 What-If Grade Calculator +

+

+ Calculate what grades you need on remaining assignments to reach your target +

+
+ +
+
+ ); + } + + return ( +
+
+

+ 🎯 What-If Grade Calculator +

+ +
+ +
+
+ + setTargetGrade(e.target.value)} + placeholder={subject.grade_system === "german" ? "2.0" : "85"} + step="0.01" + /> +
+ +
+ + setMaxGradeValue(e.target.value)} + placeholder={subject.grade_system === "german" ? "6" : "100"} + step="0.01" + /> +
+
+ +
+ +
+ {subject.grading_categories.map((category) => ( +
+
+ + {category.name} ({category.weight}%) + + + setRemainingAssignments({ + ...remainingAssignments, + [category.name]: e.target.value, + }) + } + min="0" + step="1" + className="w-20" + /> +
+ ))} +
+
+ + + + {results && ( +
+

Results:

+ {results.map((result) => ( +
90 + ? "bg-yellow-50 border-yellow-500" + : "bg-green-50 border-green-500" + }`} + > +
+
+
+ {result.categoryName} +
+

+ Weight: {result.categoryWeight}% of final grade +

+
+
+
+ {result.requiredGrade.toFixed(2)} / {result.maxGrade} +
+
+ ({result.requiredPercentage.toFixed(1)}%) +
+
+
+

+ {result.message} +

+
+ ))} + +
+

+ Note: These calculations assume each remaining + assignment has equal weight (weight = 1.0) within its category. If + your assignments have different weights, adjust accordingly. +

+
+
+ )} +
+ ); +} diff --git a/app/components/GradeTable.tsx b/app/components/GradeTable.tsx new file mode 100644 index 0000000..deda11f --- /dev/null +++ b/app/components/GradeTable.tsx @@ -0,0 +1,149 @@ +import type { Grade, Subject } from "~/types/api"; +import { formatGrade } from "~/utils/gradeCalculations"; + +interface GradeTableProps { + grades: Grade[]; + subject?: Subject; + onEdit?: (grade: Grade) => void; + onDelete?: (gradeId: string) => void; + deletingGradeId?: string | null; +} + +export function GradeTable({ grades, subject, onEdit, onDelete, deletingGradeId }: GradeTableProps) { + if (grades.length === 0) { + return ( +
+ No grades recorded yet. Add your first grade! +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {grades.map((grade) => { + const percentage = (grade.grade / grade.max_grade) * 100; + const category = subject?.grading_categories.find( + (c) => c.name === grade.category_name + ); + + return ( + + + + + + + + + + ); + })} + +
DateNameCategoryGradePercentageWeightActions
+ {new Date(grade.date).toLocaleDateString()} + +
{grade.name || "Unnamed"}
+ {grade.notes && ( +
{grade.notes}
+ )} +
+ + {grade.category_name} + + + {grade.grade} / {grade.max_grade} + + {formatGrade(percentage)}% + + {grade.weight_in_category}x + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ ); +} diff --git a/app/components/GradingCategorySelector.tsx b/app/components/GradingCategorySelector.tsx new file mode 100644 index 0000000..f14c220 --- /dev/null +++ b/app/components/GradingCategorySelector.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import type { GradingCategory } from "~/types/api"; +import { Button } from "./Button"; +import { Input } from "./Input"; + +interface GradingCategorySelectorProps { + categories: GradingCategory[]; + onChange: (categories: GradingCategory[]) => void; +} + +const DEFAULT_COLORS = [ + "#3b82f6", // blue + "#10b981", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // purple + "#ec4899", // pink +]; + +export function GradingCategorySelector({ + categories, + onChange, +}: GradingCategorySelectorProps) { + const [error, setError] = useState(""); + + const addCategory = () => { + const newCategory: GradingCategory = { + name: "", + weight: 0, + color: DEFAULT_COLORS[categories.length % DEFAULT_COLORS.length], + }; + onChange([...categories, newCategory]); + }; + + const updateCategory = (index: number, field: keyof GradingCategory, value: any) => { + const updated = [...categories]; + updated[index] = { ...updated[index], [field]: value }; + onChange(updated); + validateWeights(updated); + }; + + const removeCategory = (index: number) => { + const updated = categories.filter((_, i) => i !== index); + onChange(updated); + validateWeights(updated); + }; + + const validateWeights = (cats: GradingCategory[]) => { + const total = cats.reduce((sum, cat) => sum + (Number(cat.weight) || 0), 0); + if (Math.abs(total - 100) > 0.01 && total > 0) { + setError(`Total weight: ${total.toFixed(1)}% (must equal 100%)`); + } else { + setError(""); + } + }; + + const totalWeight = categories.reduce((sum, cat) => sum + (Number(cat.weight) || 0), 0); + + return ( +
+
+ + +
+ +
+ {categories.map((category, index) => ( +
+ updateCategory(index, "name", e.target.value)} + className="flex-1" + /> + + updateCategory(index, "weight", parseFloat(e.target.value) || 0) + } + className="w-24" + min="0" + max="100" + step="0.1" + /> + updateCategory(index, "color", e.target.value)} + className="w-12 h-10 rounded cursor-pointer" + title="Category color" + /> + +
+ ))} +
+ + {categories.length === 0 && ( +

+ Add at least one grading category +

+ )} + +
+ Total Weight: + + {totalWeight.toFixed(1)}% + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/app/components/Input.tsx b/app/components/Input.tsx new file mode 100644 index 0000000..475d149 --- /dev/null +++ b/app/components/Input.tsx @@ -0,0 +1,25 @@ +import type { InputHTMLAttributes } from "react"; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; +} + +export function Input({ label, error, className = "", ...props }: InputProps) { + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ); +} diff --git a/app/components/LoadingSpinner.tsx b/app/components/LoadingSpinner.tsx new file mode 100644 index 0000000..4e9c265 --- /dev/null +++ b/app/components/LoadingSpinner.tsx @@ -0,0 +1,19 @@ +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg"; +} + +export function LoadingSpinner({ size = "md" }: LoadingSpinnerProps) { + const sizes = { + sm: "w-4 h-4", + md: "w-8 h-8", + lg: "w-12 h-12", + }; + + return ( +
+
+
+ ); +} diff --git a/app/components/ProtectedRoute.tsx b/app/components/ProtectedRoute.tsx new file mode 100644 index 0000000..852479e --- /dev/null +++ b/app/components/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "react-router"; +import { api } from "~/api/client"; + +interface ProtectedRouteProps { + children: ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const navigate = useNavigate(); + + useEffect(() => { + if (!api.isAuthenticated()) { + navigate("/login"); + } + }, [navigate]); + + if (!api.isAuthenticated()) { + return null; + } + + return <>{children}; +} diff --git a/app/components/StatCard.tsx b/app/components/StatCard.tsx new file mode 100644 index 0000000..914af4a --- /dev/null +++ b/app/components/StatCard.tsx @@ -0,0 +1,30 @@ +interface StatCardProps { + title: string; + value: string | number; + subtitle?: string; + color?: string; + icon?: React.ReactNode; +} + +export function StatCard({ title, value, subtitle, color = "#3b82f6", icon }: StatCardProps) { + return ( +
+
+
+

{title}

+

+ {value} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ); +} diff --git a/app/components/SubjectCard.tsx b/app/components/SubjectCard.tsx new file mode 100644 index 0000000..b400c28 --- /dev/null +++ b/app/components/SubjectCard.tsx @@ -0,0 +1,114 @@ +import type { Subject } from "~/types/api"; +import { Link } from "react-router"; +import { formatGradeBySystem, type GradeSystem } from "~/utils/gradeSystems"; +import { calculateTargetProgress, getTargetStatusBgColor } from "~/utils/gradeCalculations"; + +interface SubjectCardProps { + subject: Subject; + averageGrade?: number; + calculatedAverage?: number; + gradeSystem?: string; + onDelete?: () => void; +} + +export function SubjectCard({ subject, averageGrade, calculatedAverage, gradeSystem, onDelete }: SubjectCardProps) { + // Use provided gradeSystem, fallback to subject's own system + const displaySystem = (gradeSystem || subject.grade_system || "percentage") as GradeSystem; + + // Calculate target progress if grade is available + const targetProgress = averageGrade !== undefined ? calculateTargetProgress(subject.target_grade ?? averageGrade, subject) : null; + const hasTaget = targetProgress && targetProgress.status !== "no-target"; + + const isOverridden = calculatedAverage !== undefined && averageGrade !== undefined && Math.abs(calculatedAverage - averageGrade) > 0.01; + + return ( +
+
+ +

+ {subject.name} +

+ {subject.teacher && ( +

{subject.teacher}

+ )} + + {onDelete && ( + + )} +
+ + {averageGrade !== undefined && ( +
+
+
+
+ {formatGradeBySystem(averageGrade, displaySystem, 1)} + {isOverridden && ( + + ({formatGradeBySystem(calculatedAverage!, displaySystem, 1)}) + + )} +
+

Current Average

+
+ {hasTaget && ( +
+
Target: {formatGradeBySystem(targetProgress.targetPercentage, displaySystem, 1)}
+
+ {targetProgress.status === "above" && ( + ✓ Above Target + )} + {targetProgress.status === "near" && ( + → Near Target + )} + {targetProgress.status === "below" && ( + ↓ Below Target + )} +
+
+ )} +
+
+ )} + +
+

Grading Categories:

+
+ {subject.grading_categories.map((category, index) => ( + + {category.name} ({category.weight}%) + + ))} +
+
+
+ ); +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..9fc6636 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,75 @@ +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +import type { Route } from "./+types/root"; +import "./app.css"; + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/app/routes.ts b/app/routes.ts new file mode 100644 index 0000000..f8270f6 --- /dev/null +++ b/app/routes.ts @@ -0,0 +1,13 @@ +import { type RouteConfig, index, route } from "@react-router/dev/routes"; + +export default [ + index("routes/home.tsx"), + route("login", "routes/login.tsx"), + route("register", "routes/register.tsx"), + route("dashboard", "routes/dashboard.tsx"), + route("subjects", "routes/subjects.tsx"), + route("subjects/new", "routes/subjects.new.tsx"), + route("subjects/:subjectId", "routes/subjects.$subjectId.tsx"), + route("subjects/:subjectId/edit", "routes/subjects.$subjectId.edit.tsx"), + route("periods", "routes/periods.tsx"), +] satisfies RouteConfig; diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx new file mode 100644 index 0000000..21c35a3 --- /dev/null +++ b/app/routes/dashboard.tsx @@ -0,0 +1,629 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { ProtectedRoute } from "~/components/ProtectedRoute"; +import { StatCard } from "~/components/StatCard"; +import { SubjectCard } from "~/components/SubjectCard"; +import { LoadingSpinner } from "~/components/LoadingSpinner"; +import { Button } from "~/components/Button"; +import { api } from "~/api/client"; +import type { Subject, Grade, ReportPeriod, User, TeacherGrade } from "~/types/api"; +import { calculateReportCard, calculateOverallGPA, calculateTargetProgress, getTargetStatusColor } from "~/utils/gradeCalculations"; +import { formatGradeBySystem, germanGradeToPercentage, type GradeSystem } from "~/utils/gradeSystems"; + +export default function Dashboard() { + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [subjects, setSubjects] = useState([]); + const [grades, setGrades] = useState([]); + const [periods, setPeriods] = useState([]); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [teacherGrades, setTeacherGrades] = useState([]); + const [editingTeacherGrade, setEditingTeacherGrade] = useState<{ subjectId: string; value: string; maxValue: string; notes: string } | null>(null); + const [loading, setLoading] = useState(true); + const [exporting, setExporting] = useState(false); + const [deletingTeacherGradeId, setDeletingTeacherGradeId] = useState(null); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const [userData, subjectsData, gradesData, periodsData, teacherGradesData] = await Promise.all([ + api.getMe(), + api.getSubjects(), + api.getGrades(), + api.getPeriods(), + api.getTeacherGrades(), + ]); + + setUser(userData); + setSubjects(subjectsData); + setGrades(gradesData); + setPeriods(periodsData); + setTeacherGrades(teacherGradesData); + + if (periodsData.length > 0) { + // Find the period that contains today's date + const today = new Date(); + const currentPeriod = periodsData.find(period => { + const start = new Date(period.start_date); + const end = new Date(period.end_date); + return today >= start && today <= end; + }); + + // Use current period if found, otherwise use the most recent period (first in list) + setSelectedPeriod(currentPeriod || periodsData[0]); + } + } catch (error) { + console.error("Failed to load data:", error); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + api.logout(); + navigate("/login"); + }; + + const handleTeacherGradeClick = (subjectId: string) => { + const subject = subjects.find(s => s._id === subjectId); + if (!subject || !selectedPeriod) return; + + const existing = teacherGrades.find( + tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id + ); + + const config = getGradeInputConfig(subject.grade_system as GradeSystem); + + setEditingTeacherGrade({ + subjectId, + value: existing ? existing.grade.toString() : "", + maxValue: existing ? existing.max_grade.toString() : config.maxGradeValue, + notes: existing?.notes || "", + }); + }; + + const handleSaveTeacherGrade = async () => { + if (!editingTeacherGrade || !selectedPeriod) return; + + try { + const subject = subjects.find(s => s._id === editingTeacherGrade.subjectId); + if (!subject) return; + + const config = getGradeInputConfig(subject.grade_system as GradeSystem); + const finalMaxGrade = config.showMaxGradeInput + ? parseFloat(editingTeacherGrade.maxValue) + : parseFloat(config.maxGradeValue); + + const data = { + subject_id: editingTeacherGrade.subjectId, + period_id: selectedPeriod._id, + grade: parseFloat(editingTeacherGrade.value), + max_grade: finalMaxGrade, + notes: editingTeacherGrade.notes || undefined, + }; + + const result = await api.createTeacherGrade(data); + setTeacherGrades([...teacherGrades.filter( + tg => !(tg.subject_id === result.subject_id && tg.period_id === result.period_id) + ), result]); + setEditingTeacherGrade(null); + } catch (error) { + alert("Failed to save teacher grade"); + } + }; + + const handleDeleteTeacherGrade = async (subjectId: string) => { + if (!selectedPeriod) return; + + const existing = teacherGrades.find( + tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id + ); + + if (existing && confirm("Remove teacher's grade?")) { + try { + setDeletingTeacherGradeId(subjectId); + await api.deleteTeacherGrade(existing._id); + setTeacherGrades(teacherGrades.filter(tg => tg._id !== existing._id)); + } catch (error) { + alert("Failed to delete teacher grade"); + } finally { + setDeletingTeacherGradeId(null); + } + } + }; + + const getGradeInputConfig = (gradeSystem: GradeSystem) => { + switch (gradeSystem) { + case "german": + return { + label: "German Grade", + placeholder: "1.0", + min: "1", + max: "6", + step: "0.1", + maxGradeValue: "6", + showMaxGradeInput: false, + }; + case "us-letter": + return { + label: "Percentage", + placeholder: "85", + min: "0", + max: "100", + step: "0.01", + maxGradeValue: "100", + showMaxGradeInput: false, + }; + default: + return { + label: "Grade", + placeholder: "85", + min: "0", + max: undefined, + step: "0.01", + maxGradeValue: "100", + showMaxGradeInput: true, + }; + } + }; + + const handleExportCSV = async () => { + try { + setExporting(true); + await api.exportGradesCSV(selectedPeriod?._id); + } catch (error) { + console.error("Failed to export grades:", error); + alert("Failed to export grades. Please try again."); + } finally { + setExporting(false); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + const reportCardData = selectedPeriod + ? calculateReportCard( + subjects, + grades, + new Date(selectedPeriod.start_date), + new Date(selectedPeriod.end_date) + ) + : []; + + const calculatedGPA = calculateOverallGPA(reportCardData); + + // Calculate GPA with teacher grade overrides + const getSubjectFinalGrade = (subjectId: string, calculatedAverage: number) => { + if (!selectedPeriod) return calculatedAverage; + const teacherGrade = teacherGrades.find( + tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id + ); + if (!teacherGrade) return calculatedAverage; + + const subject = subjects.find(s => s._id === subjectId); + if (!subject) return calculatedAverage; + + // Convert teacher grade to percentage + const gradeSystem = subject.grade_system as GradeSystem || "percentage"; + if (gradeSystem === "german" && teacherGrade.max_grade === 6) { + return germanGradeToPercentage(teacherGrade.grade); + } + return (teacherGrade.grade / teacherGrade.max_grade) * 100; + }; + + const overallGPA = selectedPeriod && reportCardData.length > 0 + ? reportCardData.reduce((sum, data) => { + const finalGrade = getSubjectFinalGrade(data.subject._id, data.overallAverage); + return sum + finalGrade; + }, 0) / reportCardData.length + : calculatedGPA; + + // Use the first subject's grading system as the primary system + const primaryGradeSystem = (subjects[0]?.grade_system as GradeSystem) || "percentage"; + + return ( + +
+ {/* Header */} +
+
+
+
+

Grademaxxing

+

Welcome back, {user?.username}!

+
+
+ + + + + + + +
+
+
+
+ +
+ {/* Period Selector */} + {periods.length > 0 && ( +
+
+ + +
+ +
+ )} + + {/* Stats Overview */} +
+ 0 + ? selectedPeriod && teacherGrades.some(tg => tg.period_id === selectedPeriod._id) && calculatedGPA !== overallGPA + ? `${formatGradeBySystem(overallGPA, primaryGradeSystem, 1)} (${formatGradeBySystem(calculatedGPA, primaryGradeSystem, 1)})` + : formatGradeBySystem(overallGPA, primaryGradeSystem, 1) + : "-" + } + subtitle={selectedPeriod?.name || "All time"} + color="#3b82f6" + /> + d.grades.length > 0).length} with grades`} + color="#10b981" + /> + sum + d.grades.length, 0) + : grades.length + } in period`} + color="#f59e0b" + /> +
+ + {/* Improvement Tips */} + {selectedPeriod && reportCardData.length > 0 && ( + (() => { + const tipsForSubjects = reportCardData + .filter(data => data.categoryAverages.length > 1 && data.categoryAverages.every(c => c.average > 0)) + .map(data => { + const sortedCategories = [...data.categoryAverages].sort((a, b) => a.average - b.average); + const weakest = sortedCategories[0]; + const strongest = sortedCategories[sortedCategories.length - 1]; + const difference = strongest.average - weakest.average; + + if (difference > 5) { + return { + subject: data.subject, + weakest, + strongest, + difference + }; + } + return null; + }) + .filter(tip => tip !== null); + + if (tipsForSubjects.length > 0) { + return ( +
+
+
+ + + +
+
+

💡 Improvement Tips

+
+ {tipsForSubjects.map((tip, idx) => ( +

+ {tip!.subject.name}: Your {tip!.weakest.categoryName} ({formatGradeBySystem( + tip!.weakest.average, + primaryGradeSystem, + 1 + )}) needs work compared to {tip!.strongest.categoryName} ({formatGradeBySystem( + tip!.strongest.average, + primaryGradeSystem, + 1 + )}). Focus on {tip!.weakest.categoryName.toLowerCase()} to improve! +

+ ))} +
+
+
+
+ ); + } + return null; + })() + )} + + {/* Report Card */} + {selectedPeriod && reportCardData.length > 0 ? ( +
+

+ Report Card - {selectedPeriod.name} +

+
+ + + + + {reportCardData[0]?.categoryAverages.map((cat) => ( + + ))} + + + + + + + + {reportCardData.map((data) => { + const teacherGrade = selectedPeriod ? teacherGrades.find( + tg => tg.subject_id === data.subject._id && tg.period_id === selectedPeriod._id + ) : null; + + const isEditing = editingTeacherGrade?.subjectId === data.subject._id; + + // Calculate target progress for this subject + const targetProgress = calculateTargetProgress(data.overallAverage, data.subject); + + return ( + + + {data.categoryAverages.map((cat) => ( + + ))} + + + + + + ); + })} + +
+ Subject + + {cat.categoryName} ({cat.weight}%) + + Final Grade + + Target + + Diff + + Teacher's Grade +
{data.subject.name} + {cat.average > 0 + ? formatGradeBySystem( + cat.average, + primaryGradeSystem, + 1 + ) + : "-"} + + {teacherGrade ? ( + + {formatGradeBySystem( + primaryGradeSystem === "german" && teacherGrade.max_grade === 6 + ? germanGradeToPercentage(teacherGrade.grade) + : (teacherGrade.grade / teacherGrade.max_grade) * 100, + primaryGradeSystem, + 1 + )} + {data.overallAverage > 0 && ( + + {" "}({formatGradeBySystem( + data.overallAverage, + primaryGradeSystem, + 1 + )}) + + )} + + ) : ( + + {data.overallAverage > 0 + ? formatGradeBySystem( + data.overallAverage, + primaryGradeSystem, + 1 + ) + : "-"} + + )} + + {targetProgress.status !== "no-target" + ? primaryGradeSystem === "german" && data.subject.target_grade_max === 6 + ? data.subject.target_grade?.toFixed(1) + : formatGradeBySystem( + targetProgress.targetPercentage, + primaryGradeSystem, + 1 + ) + : "-"} + + {targetProgress.status !== "no-target" && data.overallAverage > 0 + ? (targetProgress.difference > 0 ? "+" : "") + targetProgress.difference.toFixed(1) + "%" + : "-"} + + {isEditing ? ( +
+ setEditingTeacherGrade({ + ...editingTeacherGrade, + value: e.target.value + })} + className="w-20 px-2 py-1 border rounded text-sm" + placeholder={getGradeInputConfig(primaryGradeSystem).placeholder} + min={getGradeInputConfig(primaryGradeSystem).min} + max={getGradeInputConfig(primaryGradeSystem).max} + step={getGradeInputConfig(primaryGradeSystem).step} + /> + + +
+ ) : teacherGrade ? ( +
+ + {formatGradeBySystem( + primaryGradeSystem === "german" && teacherGrade.max_grade === 6 + ? germanGradeToPercentage(teacherGrade.grade) + : (teacherGrade.grade / teacherGrade.max_grade) * 100, + primaryGradeSystem, + 1 + )} + + + +
+ ) : ( + + )} +
+
+
+ ) : ( +
+

+ {periods.length === 0 ? ( + <> + No report periods defined.{" "} + + Create your first period + + + ) : ( + "No grades recorded for this period yet." + )} +

+
+ )} + + {/* Subjects Grid */} +
+

Your Subjects

+ + + +
+ + {subjects.length > 0 ? ( +
+ {subjects.map((subject) => { + const subjectGrades = grades.filter((g) => g.subject_id === subject._id); + const avgData = reportCardData.find((d) => d.subject._id === subject._id); + const teacherGrade = selectedPeriod ? teacherGrades.find( + tg => tg.subject_id === subject._id && tg.period_id === selectedPeriod._id + ) : null; + + const calculatedAverage = avgData?.overallAverage; + const finalAverage = calculatedAverage !== undefined + ? getSubjectFinalGrade(subject._id, calculatedAverage) + : undefined; + + return ( + + ); + })} +
+ ) : ( +
+

No subjects yet. Create your first subject!

+ + + +
+ )} +
+
+
+ ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx new file mode 100644 index 0000000..dbf41ec --- /dev/null +++ b/app/routes/home.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router"; +import { api } from "~/api/client"; + +export default function Home() { + const navigate = useNavigate(); + + useEffect(() => { + // Redirect to dashboard if authenticated, otherwise to login + if (api.isAuthenticated()) { + navigate("/dashboard"); + } else { + navigate("/login"); + } + }, [navigate]); + + return null; +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..ac5f146 --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,78 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate, Link } from "react-router"; +import { api } from "~/api/client"; +import { Button } from "~/components/Button"; +import { Input } from "~/components/Input"; + +export default function Login() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + await api.login({ email, password }); + navigate("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Grademaxxing

+

Sign in to your account

+
+ +
+ setEmail(e.target.value)} + placeholder="your@email.com" + required + autoComplete="email" + /> + + setPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="current-password" + /> + + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Don't have an account?{" "} + + Sign up + +

+
+
+ ); +} diff --git a/app/routes/periods.tsx b/app/routes/periods.tsx new file mode 100644 index 0000000..26e8ced --- /dev/null +++ b/app/routes/periods.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link } from "react-router"; +import { ProtectedRoute } from "~/components/ProtectedRoute"; +import { LoadingSpinner } from "~/components/LoadingSpinner"; +import { Button } from "~/components/Button"; +import { Input } from "~/components/Input"; +import { api } from "~/api/client"; +import type { ReportPeriod, ReportPeriodCreate, ReportPeriodUpdate } from "~/types/api"; + +export default function ReportPeriods() { + const [periods, setPeriods] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); + const [editingPeriod, setEditingPeriod] = useState(null); + const [name, setName] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + loadPeriods(); + }, []); + + const loadPeriods = async () => { + try { + const data = await api.getPeriods(); + setPeriods(data); + } catch (error) { + console.error("Failed to load periods:", error); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + + if (new Date(endDate) <= new Date(startDate)) { + setError("End date must be after start date"); + return; + } + + try { + if (editingPeriod) { + // Update existing period + const updateData: ReportPeriodUpdate = { + name: name.trim(), + start_date: new Date(startDate).toISOString(), + end_date: new Date(endDate).toISOString(), + }; + const updated = await api.updatePeriod(editingPeriod._id, updateData); + setPeriods(periods.map((p) => (p._id === editingPeriod._id ? updated : p))); + } else { + // Create new period + const periodData: ReportPeriodCreate = { + name: name.trim(), + start_date: new Date(startDate).toISOString(), + end_date: new Date(endDate).toISOString(), + }; + const newPeriod = await api.createPeriod(periodData); + setPeriods([newPeriod, ...periods]); + } + setName(""); + setStartDate(""); + setEndDate(""); + setShowAddForm(false); + setEditingPeriod(null); + } catch (err) { + setError(err instanceof Error ? err.message : editingPeriod ? "Failed to update period" : "Failed to create period"); + } + }; + + const handleEdit = (period: ReportPeriod) => { + setEditingPeriod(period); + setName(period.name); + setStartDate(new Date(period.start_date).toISOString().split("T")[0]); + setEndDate(new Date(period.end_date).toISOString().split("T")[0]); + setShowAddForm(true); + }; + + const handleCancelEdit = () => { + setEditingPeriod(null); + setName(""); + setStartDate(""); + setEndDate(""); + setShowAddForm(false); + }; + + const handleDelete = async (periodId: string) => { + if (!confirm("Delete this report period?")) return; + + try { + await api.deletePeriod(periodId); + setPeriods(periods.filter((p) => p._id !== periodId)); + } catch (error) { + alert("Failed to delete period"); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+
+ + ← Back to Dashboard + +

Report Periods

+

+ Define time periods for your report cards (e.g., semesters, quarters) +

+
+
+ +
+ {/* Add Period Button/Form */} +
+ {!showAddForm ? ( + + ) : ( +
+

{editingPeriod ? "Edit Report Period" : "Create Report Period"}

+
+ setName(e.target.value)} + placeholder="e.g., First Semester 2024/2025" + required + /> +
+ setStartDate(e.target.value)} + required + /> + setEndDate(e.target.value)} + required + /> +
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+ )} +
+ + {/* Periods List */} + {periods.length > 0 ? ( +
+ {periods.map((period) => ( +
+
+
+

{period.name}

+

+ {new Date(period.start_date).toLocaleDateString()} -{" "} + {new Date(period.end_date).toLocaleDateString()} +

+

+ Duration:{" "} + {Math.ceil( + (new Date(period.end_date).getTime() - + new Date(period.start_date).getTime()) / + (1000 * 60 * 60 * 24) + )}{" "} + days +

+
+
+ + +
+
+
+ ))} +
+ ) : ( +
+

+ No report periods yet. Create your first period! +

+ +
+ )} +
+
+
+ ); +} diff --git a/app/routes/register.tsx b/app/routes/register.tsx new file mode 100644 index 0000000..66238ce --- /dev/null +++ b/app/routes/register.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate, Link } from "react-router"; +import { api } from "~/api/client"; +import { Button } from "~/components/Button"; +import { Input } from "~/components/Input"; + +export default function Register() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + if (password.length < 6) { + setError("Password must be at least 6 characters"); + return; + } + + setLoading(true); + + try { + await api.register({ email, username, password }); + navigate("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Registration failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Grademaxxing

+

Create your account

+
+ +
+ setEmail(e.target.value)} + placeholder="your@email.com" + required + autoComplete="email" + /> + + setUsername(e.target.value)} + placeholder="johndoe" + required + autoComplete="username" + minLength={3} + /> + + setPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="new-password" + minLength={6} + /> + + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="new-password" + /> + + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/app/routes/subjects.$subjectId.edit.tsx b/app/routes/subjects.$subjectId.edit.tsx new file mode 100644 index 0000000..ff3640d --- /dev/null +++ b/app/routes/subjects.$subjectId.edit.tsx @@ -0,0 +1,305 @@ +import { useState, useEffect, type FormEvent } from "react"; +import { useNavigate, useParams, Link } from "react-router"; +import { ProtectedRoute } from "~/components/ProtectedRoute"; +import { Button } from "~/components/Button"; +import { Input } from "~/components/Input"; +import { GradingCategorySelector } from "~/components/GradingCategorySelector"; +import { LoadingSpinner } from "~/components/LoadingSpinner"; +import { api } from "~/api/client"; +import type { Subject, GradingCategory } from "~/types/api"; +import { GRADE_SYSTEMS, type GradeSystem } from "~/utils/gradeSystems"; + +const getMaxGradeForSystem = (system: GradeSystem): string => { + switch (system) { + case "german": + return "6"; + case "us-letter": + case "percentage": + default: + return "100"; + } +}; + +export default function EditSubject() { + const navigate = useNavigate(); + const params = useParams(); + const subjectId = params.subjectId; + + const [subject, setSubject] = useState(null); + const [name, setName] = useState(""); + const [teacher, setTeacher] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [gradeSystem, setGradeSystem] = useState("percentage"); + const [targetGrade, setTargetGrade] = useState(""); + const [targetMaxGrade, setTargetMaxGrade] = useState(""); + const [categories, setCategories] = useState([]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const fetchSubject = async () => { + if (!subjectId) { + setError("Invalid subject ID"); + setLoading(false); + return; + } + + try { + const subjects = await api.getSubjects(); + const foundSubject = subjects.find((s) => s._id === subjectId); + + if (!foundSubject) { + setError("Subject not found"); + setLoading(false); + return; + } + + setSubject(foundSubject); + setName(foundSubject.name); + setTeacher(foundSubject.teacher || ""); + setColor(foundSubject.color ?? "#3b82f6"); + setGradeSystem((foundSubject.grade_system as GradeSystem) || "percentage"); + setTargetGrade(foundSubject.target_grade?.toString() || ""); + setTargetMaxGrade(foundSubject.target_grade_max?.toString() || ""); + setCategories(foundSubject.grading_categories); + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch subject"); + setLoading(false); + } + }; + + fetchSubject(); + }, [subjectId]); + + // Auto-update max grade when grade system changes + useEffect(() => { + setTargetMaxGrade(getMaxGradeForSystem(gradeSystem)); + }, [gradeSystem]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + + if (!name.trim()) { + setError("Subject name is required"); + return; + } + + if (categories.length === 0) { + setError("Add at least one grading category"); + return; + } + + const totalWeight = categories.reduce((sum, cat) => sum + cat.weight, 0); + if (Math.abs(totalWeight - 100) > 0.01) { + setError("Category weights must sum to 100%"); + return; + } + + if (categories.some((cat) => !cat.name.trim())) { + setError("All categories must have a name"); + return; + } + + if (!subjectId) { + setError("Invalid subject ID"); + return; + } + + setSaving(true); + + try { + await api.updateSubject(subjectId, { + name: name.trim(), + teacher: teacher.trim() || undefined, + color, + grade_system: gradeSystem, + grading_categories: categories, + target_grade: targetGrade ? parseFloat(targetGrade) : undefined, + target_grade_max: targetMaxGrade ? parseFloat(targetMaxGrade) : undefined, + }); + navigate(`/subjects/${subjectId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update subject"); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error && !subject) { + return ( + +
+
+ {error} +
+
+
+ ); + } + + return ( + +
+
+
+ + ← Back to {subject?.name || "Subject"} + +

Edit Subject

+
+
+ +
+
+
+ setName(e.target.value)} + placeholder="e.g., Mathematics, English, Biology" + required + /> + + setTeacher(e.target.value)} + placeholder="e.g., Mrs. Smith" + /> + +
+ + setColor(e.target.value)} + className="w-20 h-10 rounded cursor-pointer border border-gray-300" + /> +
+ +
+ + +

+ Choose how grades will be displayed for this subject +

+
+ + + +
+

+ Target Grade (Optional) +

+
+ setTargetGrade(e.target.value)} + placeholder={gradeSystem === "german" ? "e.g., 2.0" : "e.g., 85"} + /> +
+ +
+ {targetMaxGrade} +
+

+ Auto-set by grade system +

+
+
+

+ Set a target grade to track your progress. Leave empty if not needed. +

+
+ +
+ + +

+ Choose how grades will be displayed for this subject +

+
+ +
+ +

+ ⚠️ Changing categories may affect how existing grades are calculated +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+ +
+
+
+
+ ); +} diff --git a/app/routes/subjects.$subjectId.tsx b/app/routes/subjects.$subjectId.tsx new file mode 100644 index 0000000..28a00b1 --- /dev/null +++ b/app/routes/subjects.$subjectId.tsx @@ -0,0 +1,486 @@ +import { useEffect, useState } from "react"; +import { useParams, Link, useNavigate } from "react-router"; +import { ProtectedRoute } from "~/components/ProtectedRoute"; +import { GradeTable } from "~/components/GradeTable"; +import { GradeCalculator } from "~/components/GradeCalculator"; +import { LoadingSpinner } from "~/components/LoadingSpinner"; +import { Button } from "~/components/Button"; +import { Input } from "~/components/Input"; +import { api } from "~/api/client"; +import type { Subject, Grade, GradeCreate, GradeUpdate } from "~/types/api"; +import { calculateSubjectAverage, calculateTargetProgress, getTargetStatusColor } from "~/utils/gradeCalculations"; +import { formatGradeBySystem, getGermanGradeDescription, germanGradeToPercentage, type GradeSystem } from "~/utils/gradeSystems"; + +export default function SubjectDetail() { + const { subjectId } = useParams<{ subjectId: string }>(); + const navigate = useNavigate(); + const [subject, setSubject] = useState(null); + const [grades, setGrades] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); + const [editingGrade, setEditingGrade] = useState(null); + const [deletingGradeId, setDeletingGradeId] = useState(null); + + // Form state + const [gradeName, setGradeName] = useState(""); + const [gradeValue, setGradeValue] = useState(""); + const [maxGrade, setMaxGrade] = useState("100"); + const [category, setCategory] = useState(""); + const [weight, setWeight] = useState("1"); + const [date, setDate] = useState(new Date().toISOString().split("T")[0]); + const [notes, setNotes] = useState(""); + const [error, setError] = useState(""); + + // Get input parameters based on grading system + const getGradeInputConfig = () => { + const system = (subject?.grade_system || "percentage") as GradeSystem; + switch (system) { + case "german": + return { + label: "German Grade", + placeholder: "1.0", + min: "1", + max: "6", + step: "0.1", + maxGradeValue: "6", + showMaxGradeInput: false, + helpText: "1 = sehr gut, 6 = ungenügend" + }; + case "us-letter": + return { + label: "Percentage", + placeholder: "85", + min: "0", + max: "100", + step: "0.01", + maxGradeValue: "100", + showMaxGradeInput: false, + helpText: "Enter as percentage (0-100)" + }; + default: // percentage + return { + label: "Grade", + placeholder: "85", + min: "0", + max: undefined, + step: "0.01", + maxGradeValue: "100", + showMaxGradeInput: true, + helpText: undefined + }; + } + }; + + useEffect(() => { + loadData(); + }, [subjectId]); + + const loadData = async () => { + if (!subjectId) return; + + try { + const [subjectData, gradesData] = await Promise.all([ + api.getSubject(subjectId), + api.getGrades(subjectId), + ]); + setSubject(subjectData); + setGrades(gradesData); + if (subjectData.grading_categories.length > 0) { + setCategory(subjectData.grading_categories[0].name); + } + } catch (error) { + console.error("Failed to load subject:", error); + navigate("/subjects"); + } finally { + setLoading(false); + } + }; + + const handleSubmitGrade = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!subject) return; + + const config = getGradeInputConfig(); + const finalMaxGrade = config.showMaxGradeInput ? parseFloat(maxGrade) : parseFloat(config.maxGradeValue); + + try { + if (editingGrade) { + // Update existing grade + const updateData: GradeUpdate = { + category_name: category, + grade: parseFloat(gradeValue), + max_grade: finalMaxGrade, + weight_in_category: parseFloat(weight), + name: gradeName.trim() || undefined, + date: new Date(date).toISOString(), + notes: notes.trim() || undefined, + }; + const updated = await api.updateGrade(editingGrade._id, updateData); + setGrades(grades.map((g) => (g._id === editingGrade._id ? updated : g))); + } else { + // Create new grade + const gradeData: GradeCreate = { + subject_id: subject._id, + category_name: category, + grade: parseFloat(gradeValue), + max_grade: finalMaxGrade, + weight_in_category: parseFloat(weight), + name: gradeName.trim() || undefined, + date: new Date(date).toISOString(), + notes: notes.trim() || undefined, + }; + const newGrade = await api.createGrade(gradeData); + setGrades([newGrade, ...grades]); + } + // Reset form + setGradeName(""); + setGradeValue(""); + setMaxGrade("100"); + setWeight("1"); + setDate(new Date().toISOString().split("T")[0]); + setNotes(""); + setShowAddForm(false); + setEditingGrade(null); + } catch (err) { + setError(err instanceof Error ? err.message : editingGrade ? "Failed to update grade" : "Failed to add grade"); + } + }; + + const gradeInputConfig = subject ? getGradeInputConfig() : null; + + const handleEditGrade = (grade: Grade) => { + setEditingGrade(grade); + setGradeName(grade.name || ""); + setGradeValue(grade.grade.toString()); + setMaxGrade(grade.max_grade.toString()); + setCategory(grade.category_name); + setWeight(grade.weight_in_category.toString()); + setDate(new Date(grade.date).toISOString().split("T")[0]); + setNotes(grade.notes || ""); + setShowAddForm(true); + }; + + const handleCancelEdit = () => { + setEditingGrade(null); + setGradeName(""); + setGradeValue(""); + setMaxGrade("100"); + setWeight("1"); + setDate(new Date().toISOString().split("T")[0]); + setNotes(""); + setShowAddForm(false); + }; + + const handleDeleteGrade = async (gradeId: string) => { + if (!confirm("Delete this grade?")) return; + + try { + setDeletingGradeId(gradeId); + await api.deleteGrade(gradeId); + setGrades(grades.filter((g) => g._id !== gradeId)); + } catch (error) { + alert("Failed to delete grade"); + } finally { + setDeletingGradeId(null); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (!subject) { + return null; + } + + const avgData = calculateSubjectAverage(subject, grades); + const targetProgress = calculateTargetProgress(avgData.overallAverage, subject); + + return ( + +
+
+
+ + ← Back to Subjects + +
+
+

{subject.name}

+ {subject.teacher && ( +

{subject.teacher}

+ )} +
+
+
+ {avgData.overallAverage > 0 ? formatGradeBySystem( + avgData.overallAverage, + (subject.grade_system as GradeSystem) || "percentage", + 1 + ) : "-"} +
+

Overall Average

+ {targetProgress.status !== "no-target" && avgData.overallAverage > 0 && ( +
+
+ Target: + + {formatGradeBySystem( + targetProgress.targetPercentage, + (subject.grade_system as GradeSystem) || "percentage", + 1 + )} + +
+
+ {targetProgress.difference > 0 ? "+" : ""}{targetProgress.difference.toFixed(1)}% + {targetProgress.status === "above" ? "above target" : targetProgress.status === "near" ? "near target" : "below target"} +
+
+ )} +
+
+
+
+ +
+ {/* Improvement Tip */} + {avgData.categoryAverages.length > 1 && avgData.categoryAverages.every(c => c.average > 0) && ( + (() => { + const sortedCategories = [...avgData.categoryAverages].sort((a, b) => a.average - b.average); + const weakest = sortedCategories[0]; + const strongest = sortedCategories[sortedCategories.length - 1]; + const difference = strongest.average - weakest.average; + + if (difference > 5) { // Only show if there's a significant difference + return ( +
+
+
+ + + +
+
+

� Improvement Tip

+
+

+ Your {weakest.categoryName} grades ({formatGradeBySystem( + weakest.average, + (subject.grade_system as GradeSystem) || "percentage", + 1 + )}) are lower than your {strongest.categoryName} ({formatGradeBySystem( + strongest.average, + (subject.grade_system as GradeSystem) || "percentage", + 1 + )}). + Focus more on improving your {weakest.categoryName.toLowerCase()} performance to boost your overall grade! +

+
+
+
+
+ ); + } + return null; + })() + )} + + {/* Grade Calculator */} +
+ +
+ + {/* Category Averages */} +
+ {avgData.categoryAverages.map((cat) => ( +
+

{cat.categoryName}

+

+ {cat.average > 0 ? formatGradeBySystem( + cat.average, + (subject.grade_system as GradeSystem) || "percentage", + 1 + ) : "-"} +

+

Weight: {cat.weight}%

+
+ ))} +
+ + {/* Add Grade Button/Form */} +
+ {!showAddForm ? ( +
+ + + + +
+ ) : ( +
+

{editingGrade ? "Edit Grade" : "Add New Grade"}

+
+
+ setGradeName(e.target.value)} + placeholder="e.g., Midterm Exam" + /> +
+ + +
+
+ setGradeValue(e.target.value)} + placeholder={gradeInputConfig?.placeholder || "85"} + required + min={gradeInputConfig?.min || "0"} + max={gradeInputConfig?.max} + step={gradeInputConfig?.step || "0.01"} + /> + {gradeInputConfig?.helpText && ( +

{gradeInputConfig.helpText}

+ )} +
+ {gradeInputConfig?.showMaxGradeInput && ( + setMaxGrade(e.target.value)} + placeholder="100" + required + min="0" + step="0.01" + /> + )} +
+ + {/* Grade Preview */} + {gradeValue && gradeInputConfig && ( +
+
Grade Preview
+ {subject?.grade_system === "german" ? ( + <> +
+ {parseFloat(gradeValue).toFixed(1)} +
+
+ {getGermanGradeDescription(parseFloat(gradeValue))} +
+
+ Percentage equivalent: {germanGradeToPercentage(parseFloat(gradeValue)).toFixed(1)}% +
+ + ) : ( + <> +
+ {formatGradeBySystem( + gradeInputConfig.showMaxGradeInput && maxGrade && parseFloat(maxGrade) > 0 + ? (parseFloat(gradeValue) / parseFloat(maxGrade)) * 100 + : parseFloat(gradeValue), + (subject?.grade_system as GradeSystem) || "percentage" + )} +
+ {gradeInputConfig.showMaxGradeInput && maxGrade && parseFloat(maxGrade) > 0 && ( +
+ Percentage: {((parseFloat(gradeValue) / parseFloat(maxGrade)) * 100).toFixed(1)}% +
+ )} + + )} +
+ )} + +
+ setWeight(e.target.value)} + placeholder="1" + required + min="0" + step="0.1" + /> + setDate(e.target.value)} + required + /> +
+ setNotes(e.target.value)} + placeholder="Additional notes..." + /> + {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+ )} +
+ + {/* Grades Table */} +
+

All Grades

+ +
+
+
+
+ ); +} diff --git a/app/routes/subjects.new.tsx b/app/routes/subjects.new.tsx new file mode 100644 index 0000000..e4e4758 --- /dev/null +++ b/app/routes/subjects.new.tsx @@ -0,0 +1,206 @@ +import { useState, useEffect, type FormEvent } from "react"; +import { useNavigate, Link } from "react-router"; +import { ProtectedRoute } from "~/components/ProtectedRoute"; +import { Button } from "~/components/Button"; +import { Input } from "~/components/Input"; +import { GradingCategorySelector } from "~/components/GradingCategorySelector"; +import { api } from "~/api/client"; +import type { GradingCategory } from "~/types/api"; +import { GRADE_SYSTEMS, type GradeSystem } from "~/utils/gradeSystems"; + +const getMaxGradeForSystem = (system: GradeSystem): string => { + switch (system) { + case "german": + return "6"; + case "us-letter": + case "percentage": + default: + return "100"; + } +}; + +export default function NewSubject() { + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [teacher, setTeacher] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [gradeSystem, setGradeSystem] = useState("percentage"); + const [targetGrade, setTargetGrade] = useState(""); + const [targetMaxGrade, setTargetMaxGrade] = useState("100"); + + // Auto-update max grade when grade system changes + useEffect(() => { + setTargetMaxGrade(getMaxGradeForSystem(gradeSystem)); + }, [gradeSystem]); + const [categories, setCategories] = useState([ + { name: "Written", weight: 50, color: "#3b82f6" }, + { name: "Oral", weight: 50, color: "#10b981" }, + ]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + + if (!name.trim()) { + setError("Subject name is required"); + return; + } + + if (categories.length === 0) { + setError("Add at least one grading category"); + return; + } + + const totalWeight = categories.reduce((sum, cat) => sum + cat.weight, 0); + if (Math.abs(totalWeight - 100) > 0.01) { + setError("Category weights must sum to 100%"); + return; + } + + if (categories.some((cat) => !cat.name.trim())) { + setError("All categories must have a name"); + return; + } + + setLoading(true); + + try { + await api.createSubject({ + name: name.trim(), + teacher: teacher.trim() || undefined, + color, + grade_system: gradeSystem, + grading_categories: categories, + target_grade: targetGrade ? parseFloat(targetGrade) : undefined, + target_grade_max: targetMaxGrade ? parseFloat(targetMaxGrade) : undefined, + }); + navigate("/subjects"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create subject"); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+
+ + ← Back to Subjects + +

Create New Subject

+
+
+ +
+
+
+ setName(e.target.value)} + placeholder="e.g., Mathematics, English, Biology" + required + /> + + setTeacher(e.target.value)} + placeholder="e.g., Mrs. Smith" + /> + +
+ + setColor(e.target.value)} + className="w-20 h-10 rounded cursor-pointer border border-gray-300" + /> +
+ +
+ + +

+ Choose how grades will be displayed for this subject +

+
+ + + +
+

+ Target Grade (Optional) +

+
+ setTargetGrade(e.target.value)} + placeholder={gradeSystem === "german" ? "e.g., 2.0" : "e.g., 85"} + /> +
+ +
+ {targetMaxGrade} +
+

+ Auto-set by grade system +

+
+
+

+ Set a target grade to track your progress. Leave empty if not needed. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+ +
+
+
+
+ ); +} diff --git a/app/routes/subjects.tsx b/app/routes/subjects.tsx new file mode 100644 index 0000000..2bd3de6 --- /dev/null +++ b/app/routes/subjects.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router"; +import { ProtectedRoute } from "~/components/ProtectedRoute"; +import { SubjectCard } from "~/components/SubjectCard"; +import { LoadingSpinner } from "~/components/LoadingSpinner"; +import { Button } from "~/components/Button"; +import { api } from "~/api/client"; +import type { Subject, Grade } from "~/types/api"; +import { calculateSubjectAverage } from "~/utils/gradeCalculations"; + +export default function SubjectsList() { + const [subjects, setSubjects] = useState([]); + const [grades, setGrades] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const [subjectsData, gradesData] = await Promise.all([ + api.getSubjects(), + api.getGrades(), + ]); + setSubjects(subjectsData); + setGrades(gradesData); + } catch (error) { + console.error("Failed to load subjects:", error); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (subjectId: string) => { + if (!confirm("Are you sure? This will delete all grades for this subject.")) { + return; + } + + try { + await api.deleteSubject(subjectId); + setSubjects(subjects.filter((s) => s._id !== subjectId)); + setGrades(grades.filter((g) => g.subject_id !== subjectId)); + } catch (error) { + alert("Failed to delete subject"); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + // Use the first subject's grading system as the primary system + const primaryGradeSystem = subjects[0]?.grade_system || "percentage"; + + return ( + +
+
+
+
+
+ + ← Back to Dashboard + +

Manage Subjects

+
+ + + +
+
+
+ +
+ {subjects.length > 0 ? ( +
+ {subjects.map((subject) => { + const subjectGrades = grades.filter((g) => g.subject_id === subject._id); + const avgData = calculateSubjectAverage(subject, subjectGrades); + return ( + handleDelete(subject._id)} + /> + ); + })} +
+ ) : ( +
+

No subjects yet. Create your first subject!

+ + + +
+ )} +
+
+
+ ); +} diff --git a/app/types/api.ts b/app/types/api.ts new file mode 100644 index 0000000..029d997 --- /dev/null +++ b/app/types/api.ts @@ -0,0 +1,149 @@ +// API Types matching backend models + +export interface GradingCategory { + name: string; + weight: number; + color?: string; +} + +export interface Subject { + _id: string; + name: string; + grading_categories: GradingCategory[]; + color?: string; + teacher?: string; + grade_system?: string; + target_grade?: number; + target_grade_max?: number; + created_at: string; + updated_at: string; +} + +export interface SubjectCreate { + name: string; + grading_categories: GradingCategory[]; + color?: string; + teacher?: string; + grade_system?: string; + target_grade?: number; + target_grade_max?: number; +} + +export interface SubjectUpdate { + name?: string; + grading_categories?: GradingCategory[]; + color?: string; + teacher?: string; + grade_system?: string; + target_grade?: number; + target_grade_max?: number; +} + +export interface Grade { + _id: string; + subject_id: string; + category_name: string; + grade: number; + max_grade: number; + weight_in_category: number; + name?: string; + date: string; + notes?: string; + created_at: string; + updated_at: string; +} + +export interface GradeCreate { + subject_id: string; + category_name: string; + grade: number; + max_grade: number; + weight_in_category?: number; + name?: string; + date?: string; + notes?: string; +} + +export interface GradeUpdate { + category_name?: string; + grade?: number; + max_grade?: number; + weight_in_category?: number; + name?: string; + date?: string; + notes?: string; +} + +export interface ReportPeriod { + _id: string; + name: string; + start_date: string; + end_date: string; + created_at: string; + updated_at: string; +} + +export interface ReportPeriodCreate { + name: string; + start_date: string; + end_date: string; +} + +export interface ReportPeriodUpdate { + name?: string; + start_date?: string; + end_date?: string; +} + +export interface TeacherGrade { + _id: string; + subject_id: string; + period_id: string; + grade: number; + max_grade: number; + notes?: string; + created_at: string; + updated_at: string; +} + +export interface TeacherGradeCreate { + subject_id: string; + period_id: string; + grade: number; + max_grade: number; + notes?: string; +} + +export interface TeacherGradeUpdate { + grade?: number; + max_grade?: number; + notes?: string; +} + +export interface User { + _id: string; + email: string; + username: string; + created_at: string; +} + +export interface UserLogin { + email: string; + password: string; +} + +export interface UserRegister { + email: string; + username: string; + password: string; +} + +export interface AuthResponse { + access_token: string; + token_type: string; + user: User; +} + +export interface ApiError { + detail: string; +} diff --git a/app/utils/gradeCalculations.ts b/app/utils/gradeCalculations.ts new file mode 100644 index 0000000..dd7eb22 --- /dev/null +++ b/app/utils/gradeCalculations.ts @@ -0,0 +1,431 @@ +import type { Subject, Grade, GradingCategory } from "~/types/api"; +import { germanGradeToPercentage, percentageToGermanGrade } from "./gradeSystems"; + +export interface CategoryAverage { + categoryName: string; + average: number; // Always stored as percentage (0-100) for calculations + weight: number; + color?: string; +} + +export interface SubjectGradeData { + subject: Subject; + grades: Grade[]; + categoryAverages: CategoryAverage[]; + overallAverage: number; // Always stored as percentage (0-100) for calculations +} + +/** + * Calculate the weighted average for a specific grading category + * @param grades - Array of grades in the category + * @param isGermanSystem - Whether the grades use the German 1-6 system (inverted scale) + */ +export function calculateCategoryAverage(grades: Grade[], isGermanSystem: boolean = false): number { + if (grades.length === 0) return 0; + + const totalWeight = grades.reduce((sum, grade) => sum + grade.weight_in_category, 0); + if (totalWeight === 0) return 0; + + const weightedSum = grades.reduce((sum, grade) => { + // Normalize grade to percentage + let percentage: number; + + if (isGermanSystem && grade.max_grade === 6) { + // For German grades (1-6 scale), convert to percentage using the conversion function + percentage = germanGradeToPercentage(grade.grade); + } else { + // For standard percentage-based grades + percentage = (grade.grade / grade.max_grade) * 100; + } + + return sum + percentage * grade.weight_in_category; + }, 0); + + return weightedSum / totalWeight; +} + +/** + * Calculate the overall average for a subject based on category weights + */ +export function calculateSubjectAverage( + subject: Subject, + grades: Grade[] +): SubjectGradeData { + const categoryAverages: CategoryAverage[] = []; + const isGermanSystem = subject.grade_system === "german"; + + // Group grades by category + const gradesByCategory = new Map(); + grades.forEach((grade) => { + const existing = gradesByCategory.get(grade.category_name) || []; + existing.push(grade); + gradesByCategory.set(grade.category_name, existing); + }); + + // Calculate average for each category + subject.grading_categories.forEach((category) => { + const categoryGrades = gradesByCategory.get(category.name) || []; + const average = calculateCategoryAverage(categoryGrades, isGermanSystem); + + categoryAverages.push({ + categoryName: category.name, + average, + weight: category.weight, + color: category.color, + }); + }); + + // Calculate overall weighted average + const overallAverage = categoryAverages.reduce((sum, cat) => { + return sum + (cat.average * cat.weight) / 100; + }, 0); + + return { + subject, + grades, + categoryAverages, + overallAverage, + }; +} + +/** + * Calculate report card averages for all subjects within a date range + */ +export function calculateReportCard( + subjects: Subject[], + allGrades: Grade[], + startDate: Date, + endDate: Date +): SubjectGradeData[] { + return subjects.map((subject) => { + // Filter grades for this subject within the date range + const subjectGrades = allGrades.filter((grade) => { + const gradeDate = new Date(grade.date); + return ( + grade.subject_id === subject._id && + gradeDate >= startDate && + gradeDate <= endDate + ); + }); + + return calculateSubjectAverage(subject, subjectGrades); + }); +} + +/** + * Calculate overall GPA across all subjects + */ +export function calculateOverallGPA(reportCardData: SubjectGradeData[]): number { + if (reportCardData.length === 0) return 0; + + const sum = reportCardData.reduce( + (total, data) => total + data.overallAverage, + 0 + ); + + return sum / reportCardData.length; +} + +/** + * Format a percentage grade to a specific decimal place + */ +export function formatGrade(grade: number, decimals: number = 2): string { + return grade.toFixed(decimals); +} + +/** + * Format a grade for display based on the grading system + * For German system: converts percentage to 1-6 scale (e.g., "2.3") + * For other systems: shows percentage with % sign (e.g., "85%") + * + * @param percentage - Grade as percentage (0-100) + * @param gradeSystem - The grading system being used + * @param decimals - Number of decimal places + */ +export function formatGradeForDisplay( + percentage: number, + gradeSystem: string = "percentage", + decimals: number = 1 +): string { + if (gradeSystem === "german") { + const germanGrade = percentageToGermanGrade(percentage); + return germanGrade.toFixed(decimals); + } + return `${percentage.toFixed(decimals)}%`; +} + +/** + * Get the native grade value (not percentage) based on the grading system + * For German system: returns 1-6 scale value + * For other systems: returns percentage + * + * @param percentage - Grade as percentage (0-100) + * @param gradeSystem - The grading system being used + */ +export function getDisplayGrade( + percentage: number, + gradeSystem: string = "percentage" +): number { + if (gradeSystem === "german") { + return percentageToGermanGrade(percentage); + } + return percentage; +} + +/** + * Convert percentage to letter grade (US system) + */ +export function percentageToLetterGrade(percentage: number): string { + if (percentage >= 90) return "A"; + if (percentage >= 80) return "B"; + if (percentage >= 70) return "C"; + if (percentage >= 60) return "D"; + return "F"; +} + +/** + * Get color for grade based on percentage + */ +export function getGradeColor(percentage: number): string { + if (percentage >= 90) return "text-green-600"; + if (percentage >= 80) return "text-blue-600"; + if (percentage >= 70) return "text-yellow-600"; + if (percentage >= 60) return "text-orange-600"; + return "text-red-600"; +} + +export interface TargetProgress { + currentGrade: number; + targetGrade: number; + targetMaxGrade: number; + currentPercentage: number; + targetPercentage: number; + difference: number; // Positive = above target, negative = below target + percentageDifference: number; // Difference as percentage + status: "above" | "near" | "below" | "no-target"; // above = exceeds target, near = within 5%, below = more than 5% below +} + +/** + * Calculate target progress for a subject + * @param currentAverage - Current subject average (as percentage) + * @param subject - Subject with optional target_grade and target_grade_max + */ +export function calculateTargetProgress( + currentAverage: number, + subject: Subject +): TargetProgress { + // No target set + if (!subject.target_grade || !subject.target_grade_max) { + return { + currentGrade: currentAverage, + targetGrade: 0, + targetMaxGrade: 100, + currentPercentage: currentAverage, + targetPercentage: 0, + difference: 0, + percentageDifference: 0, + status: "no-target", + }; + } + + // Convert target to percentage + const isGermanSystem = subject.grade_system === "german" && subject.target_grade_max === 6; + let targetPercentage: number; + + if (isGermanSystem) { + // For German grades (1-6 scale), convert to percentage using the conversion function + targetPercentage = germanGradeToPercentage(subject.target_grade); + } else { + // For standard percentage-based grades + targetPercentage = (subject.target_grade / subject.target_grade_max) * 100; + } + + const difference = currentAverage - targetPercentage; + const percentageDifference = targetPercentage > 0 ? (difference / targetPercentage) * 100 : 0; + + // Determine status + let status: "above" | "near" | "below"; + if (difference > 0) { + status = "above"; + } else if (Math.abs(difference) <= 5) { + status = "near"; + } else { + status = "below"; + } + + return { + currentGrade: currentAverage, + targetGrade: subject.target_grade, + targetMaxGrade: subject.target_grade_max, + currentPercentage: currentAverage, + targetPercentage, + difference, + percentageDifference, + status, + }; +} + +/** + * Get color class for target progress status + */ +export function getTargetStatusColor(status: TargetProgress["status"]): string { + switch (status) { + case "above": + return "text-green-600"; + case "near": + return "text-yellow-600"; + case "below": + return "text-red-600"; + default: + return "text-gray-600"; + } +} + +/** + * Get background color class for target progress status + */ +export function getTargetStatusBgColor(status: TargetProgress["status"]): string { + switch (status) { + case "above": + return "bg-green-100 border-green-500"; + case "near": + return "bg-yellow-100 border-yellow-500"; + case "below": + return "bg-red-100 border-red-500"; + default: + return "bg-gray-100 border-gray-300"; + } +} + +export interface RequiredGradeResult { + requiredGrade: number; // Grade needed on remaining assignments + maxGrade: number; // Maximum grade value in the system + requiredPercentage: number; // Required grade as percentage + isPossible: boolean; // Whether target is achievable + categoryName: string; + categoryWeight: number; + message: string; // Human-readable explanation +} + +/** + * Calculate what grade is needed on remaining assignments to reach a target grade + * + * @param subject - The subject with grading categories + * @param currentGrades - Current grades for the subject + * @param targetPercentage - Target overall average (as percentage) + * @param remainingAssignments - Number of remaining assignments per category + * @param maxGrade - Maximum grade value (e.g., 6 for German, 100 for percentage) + * @returns Array of required grades per category + */ +export function calculateRequiredGrade( + subject: Subject, + currentGrades: Grade[], + targetPercentage: number, + remainingAssignments: { [categoryName: string]: number }, + maxGrade: number = 100 +): RequiredGradeResult[] { + const isGermanSystem = subject.grade_system === "german" && maxGrade === 6; + + // Group grades by category + const gradesByCategory = new Map(); + currentGrades.forEach((grade) => { + const existing = gradesByCategory.get(grade.category_name) || []; + existing.push(grade); + gradesByCategory.set(grade.category_name, existing); + }); + + const results: RequiredGradeResult[] = []; + + subject.grading_categories.forEach((category) => { + const categoryGrades = gradesByCategory.get(category.name) || []; + const currentCategoryAverage = calculateCategoryAverage(categoryGrades, isGermanSystem); + const remainingCount = remainingAssignments[category.name] || 0; + + // Calculate what category average is needed + // targetPercentage = sum of (categoryAvg * categoryWeight / 100) + // We need to solve for the new category average considering remaining assignments + + let requiredPercentage: number; + let isPossible = true; + let message = ""; + + if (remainingCount === 0) { + // No remaining assignments in this category + requiredPercentage = currentCategoryAverage; + isPossible = Math.abs(currentCategoryAverage - targetPercentage) < 0.01; + message = "No remaining assignments in this category"; + } else { + // Calculate the contribution of other categories at their current averages + let otherCategoriesContribution = 0; + subject.grading_categories.forEach((cat) => { + if (cat.name !== category.name) { + const catGrades = gradesByCategory.get(cat.name) || []; + const catAvg = calculateCategoryAverage(catGrades, isGermanSystem); + otherCategoriesContribution += (catAvg * cat.weight) / 100; + } + }); + + // Required contribution from this category + const requiredContribution = targetPercentage - otherCategoriesContribution; + requiredPercentage = (requiredContribution * 100) / category.weight; + + // Calculate what grade percentage is needed on new assignments + // New category average = (currentSum + requiredGrade * remainingCount) / (currentCount + remainingCount) + const currentCount = categoryGrades.length; + const currentTotalWeight = categoryGrades.reduce((sum, g) => sum + g.weight_in_category, 0); + + // Assuming each new assignment has weight = 1 + const newTotalWeight = currentTotalWeight + remainingCount; + + // currentCategoryAverage = currentSum / currentTotalWeight + // So currentSum = currentCategoryAverage * currentTotalWeight + const currentSum = currentCategoryAverage * currentTotalWeight; + + // requiredPercentage = (currentSum + requiredGradePercentage * remainingCount) / newTotalWeight + // Solve for requiredGradePercentage: + requiredPercentage = (requiredPercentage * newTotalWeight - currentSum) / remainingCount; + + // Check if achievable + if (requiredPercentage > 100) { + isPossible = false; + const maxDisplay = formatGradeForDisplay(100, subject.grade_system); + message = `Target unreachable - would need ${formatGradeForDisplay(requiredPercentage, subject.grade_system)} (>${maxDisplay})`; + } else if (requiredPercentage < 0) { + isPossible = true; + const minDisplay = formatGradeForDisplay(0, subject.grade_system); + message = `Target already exceeded! You can score ${minDisplay} and still reach your goal.`; + requiredPercentage = 0; + } else { + isPossible = true; + message = `Need ${formatGradeForDisplay(requiredPercentage, subject.grade_system)} average on ${remainingCount} remaining assignment${remainingCount > 1 ? 's' : ''}`; + } + } + + // Convert percentage to grade value + let requiredGrade: number; + if (isGermanSystem) { + // For German system, convert percentage back to 1-6 scale + // This is an approximation - exact conversion depends on the grading curve + if (requiredPercentage >= 92) requiredGrade = 1.0; + else if (requiredPercentage >= 81) requiredGrade = 2.0; + else if (requiredPercentage >= 67) requiredGrade = 3.0; + else if (requiredPercentage >= 50) requiredGrade = 4.0; + else if (requiredPercentage >= 30) requiredGrade = 5.0; + else requiredGrade = 6.0; + } else { + requiredGrade = (requiredPercentage / 100) * maxGrade; + } + + results.push({ + requiredGrade, + maxGrade, + requiredPercentage, + isPossible, + categoryName: category.name, + categoryWeight: category.weight, + message, + }); + }); + + return results; +} diff --git a/app/utils/gradeSystems.ts b/app/utils/gradeSystems.ts new file mode 100644 index 0000000..ca677be --- /dev/null +++ b/app/utils/gradeSystems.ts @@ -0,0 +1,179 @@ +/** + * German Grading System Utilities + * German grades: 1 (best) to 6 (worst) + */ + +export type GradeSystem = "percentage" | "german" | "us-letter"; + +export interface GradeSystemConfig { + type: GradeSystem; + displayName: string; + description: string; +} + +export const GRADE_SYSTEMS: Record = { + percentage: { + type: "percentage", + displayName: "Percentage (0-100%)", + description: "Standard percentage-based grading", + }, + german: { + type: "german", + displayName: "German (1-6)", + description: "1 = sehr gut (very good), 6 = ungenügend (insufficient)", + }, + "us-letter": { + type: "us-letter", + displayName: "US Letter (A-F)", + description: "A = 90-100%, F = below 60%", + }, +}; + +/** + * Convert percentage (0-100) to German grade (1-6) + * German grading scale: + * 1 (sehr gut / very good): 92-100% + * 2 (gut / good): 81-91% + * 3 (befriedigend / satisfactory): 67-80% + * 4 (ausreichend / sufficient): 50-66% + * 5 (mangelhaft / deficient): 30-49% + * 6 (ungenügend / insufficient): 0-29% + */ +export function percentageToGermanGrade(percentage: number): number { + if (percentage >= 92) return 1.0; + if (percentage >= 81) return 2.0; + if (percentage >= 67) return 3.0; + if (percentage >= 50) return 4.0; + if (percentage >= 30) return 5.0; + return 6.0; +} + +/** + * Convert percentage to German grade with decimal precision + * Uses linear interpolation within each grade range + */ +export function percentageToGermanGradeDetailed(percentage: number): number { + if (percentage >= 92) { + // 1.0 - 1.5 range (92-100%) + const position = (100 - percentage) / (100 - 92); + return 1.0 + position * 0.5; + } else if (percentage >= 81) { + // 1.5 - 2.5 range (81-91%) + const position = (92 - percentage) / (92 - 81); + return 1.5 + position * 1.0; + } else if (percentage >= 67) { + // 2.5 - 3.5 range (67-80%) + const position = (81 - percentage) / (81 - 67); + return 2.5 + position * 1.0; + } else if (percentage >= 50) { + // 3.5 - 4.5 range (50-66%) + const position = (67 - percentage) / (67 - 50); + return 3.5 + position * 1.0; + } else if (percentage >= 30) { + // 4.5 - 5.5 range (30-49%) + const position = (50 - percentage) / (50 - 30); + return 4.5 + position * 1.0; + } else { + // 5.5 - 6.0 range (0-29%) + const position = (30 - percentage) / 30; + return 5.5 + position * 0.5; + } +} + +/** + * Convert German grade (1-6) to approximate percentage + */ +export function germanGradeToPercentage(grade: number): number { + if (grade <= 1.5) return 92 + (1.5 - grade) * 16; // 92-100% + if (grade <= 2.5) return 81 + (2.5 - grade) * 11; // 81-91% + if (grade <= 3.5) return 67 + (3.5 - grade) * 14; // 67-80% + if (grade <= 4.5) return 50 + (4.5 - grade) * 17; // 50-66% + if (grade <= 5.5) return 30 + (5.5 - grade) * 20; // 30-49% + return Math.max(0, 30 - (grade - 5.5) * 60); // 0-29% +} + +/** + * Get German grade description + */ +export function getGermanGradeDescription(grade: number): string { + if (grade <= 1.5) return "sehr gut (very good)"; + if (grade <= 2.5) return "gut (good)"; + if (grade <= 3.5) return "befriedigend (satisfactory)"; + if (grade <= 4.5) return "ausreichend (sufficient)"; + if (grade <= 5.5) return "mangelhaft (deficient)"; + return "ungenügend (insufficient)"; +} + +/** + * Get color for German grade + */ +export function getGermanGradeColor(grade: number): string { + if (grade <= 2.0) return "text-green-600"; + if (grade <= 3.0) return "text-blue-600"; + if (grade <= 4.0) return "text-yellow-600"; + if (grade <= 5.0) return "text-orange-600"; + return "text-red-600"; +} + +/** + * Format grade based on system + */ +export function formatGradeBySystem( + percentage: number, + system: GradeSystem, + decimals: number = 2 +): string { + switch (system) { + case "german": { + const germanGrade = percentageToGermanGradeDetailed(percentage); + return germanGrade.toFixed(1); + } + case "us-letter": { + if (percentage >= 90) return "A"; + if (percentage >= 80) return "B"; + if (percentage >= 70) return "C"; + if (percentage >= 60) return "D"; + return "F"; + } + case "percentage": + default: + return `${percentage.toFixed(decimals)}%`; + } +} + +/** + * Get color for grade based on system + */ +export function getGradeColorBySystem( + percentage: number, + system: GradeSystem +): string { + switch (system) { + case "german": { + const germanGrade = percentageToGermanGradeDetailed(percentage); + return getGermanGradeColor(germanGrade); + } + case "percentage": + case "us-letter": + default: + if (percentage >= 90) return "text-green-600"; + if (percentage >= 80) return "text-blue-600"; + if (percentage >= 70) return "text-yellow-600"; + if (percentage >= 60) return "text-orange-600"; + return "text-red-600"; + } +} + +/** + * Get description for grade + */ +export function getGradeDescription( + percentage: number, + system: GradeSystem +): string | null { + if (system === "german") { + const germanGrade = percentageToGermanGradeDetailed(percentage); + return getGermanGradeDescription(germanGrade); + } + return null; +} diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..9e223ac --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt # type: ignore[import] +from passlib.context import CryptContext +from config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """Decode a JWT access token""" + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + return payload + except JWTError: + return None diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..af0af5f --- /dev/null +++ b/backend/config.py @@ -0,0 +1,23 @@ +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + mongodb_url: str + database_name: str = "grademaxxing" + secret_key: str + algorithm: str = "HS256" + access_token_expire_minutes: int = 43200 # 30 days + vite_api_url: str = "http://localhost:8000/api" + cors_origins: str = "http://localhost:5173,http://localhost:8000,http://127.0.0.1:8000" + + @property + def cors_origins_list(self) -> List[str]: + return [origin.strip() for origin in self.cors_origins.split(",")] + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..243e394 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,26 @@ +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +from config import settings + +client: AsyncIOMotorClient = None +database: AsyncIOMotorDatabase = None + + +async def connect_to_mongo(): + """Connect to MongoDB""" + global client, database + client = AsyncIOMotorClient(settings.mongodb_url) + database = client[settings.database_name] + print(f"Connected to MongoDB at {settings.mongodb_url}") + + +async def close_mongo_connection(): + """Close MongoDB connection""" + global client + if client: + client.close() + print("Closed MongoDB connection") + + +def get_database() -> AsyncIOMotorDatabase: + """Get database instance""" + return database diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..1b6fc10 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,168 @@ +from fastapi import FastAPI, HTTPException, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, StreamingResponse +from contextlib import asynccontextmanager +import os +from pathlib import Path +from io import StringIO +import csv +from typing import Optional + +from config import settings +from database import connect_to_mongo, close_mongo_connection, get_database +from routes import auth_routes, subject_routes, grade_routes, period_routes, teacher_grade_routes +from routes.auth_routes import get_current_user +from models import UserInDB + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan events""" + # Startup + await connect_to_mongo() + yield + # Shutdown + await close_mongo_connection() + + +app = FastAPI( + title="Grademaxxing API", + description="Grade management system API", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routes +app.include_router(auth_routes.router, prefix="/api") +app.include_router(subject_routes.router, prefix="/api") +app.include_router(grade_routes.router, prefix="/api") +app.include_router(period_routes.router, prefix="/api") +app.include_router(teacher_grade_routes.router, prefix="/api") + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "version": "1.0.0"} + + +@app.get("/api/export/grades") +async def export_grades_csv( + period_id: Optional[str] = None, + current_user: UserInDB = Depends(get_current_user) +): + """Export all grades as CSV, optionally filtered by period""" + db = get_database() + + # Get all subjects for the user + subjects_cursor = db.subjects.find({"user_id": current_user.id}) + subjects = await subjects_cursor.to_list(length=None) + subject_dict = {str(subject["_id"]): subject for subject in subjects} + + # Build grade query + grade_query = {"user_id": current_user.id} + + # If period_id is provided, filter grades by date range + if period_id: + period = await db.periods.find_one({"_id": period_id, "user_id": current_user.id}) + if period: + grade_query["date"] = { + "$gte": period["start_date"], + "$lte": period["end_date"] + } + + # Get all grades + grades_cursor = db.grades.find(grade_query).sort("date", -1) + grades = await grades_cursor.to_list(length=None) + + # Create CSV in memory + output = StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + "Subject", + "Category", + "Grade Name", + "Grade", + "Max Grade", + "Percentage", + "Weight in Category", + "Date", + "Notes" + ]) + + # Write grade rows + for grade in grades: + subject = subject_dict.get(grade["subject_id"]) + subject_name = subject["name"] if subject else "Unknown Subject" + + # Calculate percentage + percentage = (grade["grade"] / grade["max_grade"] * 100) if grade["max_grade"] > 0 else 0 + + writer.writerow([ + subject_name, + grade.get("category_name", ""), + grade.get("name", ""), + grade["grade"], + grade["max_grade"], + f"{percentage:.2f}%", + grade.get("weight_in_category", 1.0), + grade["date"].strftime("%Y-%m-%d") if "date" in grade else "", + grade.get("notes", "") + ]) + + # Prepare response + output.seek(0) + filename = f"grades_export_{current_user.username}.csv" + if period_id: + period = await db.periods.find_one({"_id": period_id, "user_id": current_user.id}) + if period: + filename = f"grades_{period['name'].replace(' ', '_')}.csv" + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +# Serve static files (React SPA) +static_dir = Path(__file__).parent.parent / "build" / "client" + +if static_dir.exists(): + app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets") + + @app.get("/{full_path:path}") + async def serve_spa(full_path: str): + """Serve React SPA for all non-API routes""" + # Check if the file exists + file_path = static_dir / full_path + + if file_path.is_file(): + return FileResponse(file_path) + + # For all other routes, serve index.html (SPA routing) + index_path = static_dir / "index.html" + if index_path.exists(): + return FileResponse(index_path) + + raise HTTPException(status_code=404, detail="Not found") +else: + print(f"Warning: Static directory not found at {static_dir}") + print("Run the frontend build first to serve the UI") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..1296fd0 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,265 @@ +from pydantic import BaseModel, EmailStr, Field, field_validator +from typing import Optional, List, Dict +from datetime import datetime +from bson import ObjectId + + +class PyObjectId(ObjectId): + """Custom ObjectId type for Pydantic""" + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not ObjectId.is_valid(v): + raise ValueError("Invalid ObjectId") + return ObjectId(v) + + @classmethod + def __get_pydantic_json_schema__(cls, field_schema): + field_schema.update(type="string") + + +# User Models +class UserBase(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserInDB(UserBase): + id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") + hashed_password: str + created_at: datetime = Field(default_factory=datetime.utcnow) + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +class UserResponse(UserBase): + id: str = Field(..., alias="_id") + created_at: datetime + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + + +# Grading Category Model (flexible, not German-specific) +class GradingCategory(BaseModel): + name: str = Field(..., min_length=1, max_length=100) # e.g., "Written", "Oral", "Homework" + weight: float = Field(..., ge=0, le=100) # Percentage weight (0-100) + color: Optional[str] = Field(default="#3b82f6") # Hex color for UI + + +# Subject Model +class SubjectBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + grading_categories: List[GradingCategory] = Field(..., min_items=1) + color: Optional[str] = Field(default="#3b82f6") + teacher: Optional[str] = None + grade_system: Optional[str] = Field(default="percentage") # "percentage", "german", "us-letter" + target_grade: Optional[float] = None + target_grade_max: Optional[float] = None + + @field_validator("grading_categories") + @classmethod + def validate_weights(cls, categories: List[GradingCategory]) -> List[GradingCategory]: + total_weight = sum(cat.weight for cat in categories) + if abs(total_weight - 100.0) > 0.01: # Allow small floating point errors + raise ValueError(f"Grading category weights must sum to 100%, got {total_weight}%") + return categories + + +class SubjectCreate(SubjectBase): + pass + + +class SubjectUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + grading_categories: Optional[List[GradingCategory]] = None + color: Optional[str] = None + teacher: Optional[str] = None + grade_system: Optional[str] = None + target_grade: Optional[float] = None + target_grade_max: Optional[float] = None + + @field_validator("grading_categories") + @classmethod + def validate_weights(cls, categories: Optional[List[GradingCategory]]) -> Optional[List[GradingCategory]]: + if categories is None: + return None + total_weight = sum(cat.weight for cat in categories) + if abs(total_weight - 100.0) > 0.01: + raise ValueError(f"Grading category weights must sum to 100%, got {total_weight}%") + return categories + + +class SubjectInDB(SubjectBase): + id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") + user_id: str + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +class SubjectResponse(SubjectBase): + id: str = Field(..., alias="_id") + created_at: datetime + updated_at: datetime + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +# Test/Grade Model +class GradeBase(BaseModel): + subject_id: str + category_name: str # Must match one of the subject's grading categories + grade: float = Field(..., ge=0) # Grade value (e.g., 1.0 to 6.0 for German, 0-100 for percentage) + max_grade: float = Field(..., ge=0) # Maximum possible grade (e.g., 6.0 or 100) + weight_in_category: float = Field(default=1.0, ge=0) # Weight within the category (for averaging multiple tests) + name: Optional[str] = Field(None, max_length=200) # Test name/description + date: datetime = Field(default_factory=datetime.utcnow) + notes: Optional[str] = None + + +class GradeCreate(GradeBase): + pass + + +class GradeUpdate(BaseModel): + category_name: Optional[str] = None + grade: Optional[float] = Field(None, ge=0) + max_grade: Optional[float] = Field(None, ge=0) + weight_in_category: Optional[float] = Field(None, ge=0) + name: Optional[str] = Field(None, max_length=200) + date: Optional[datetime] = None + notes: Optional[str] = None + + +class GradeInDB(GradeBase): + id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") + user_id: str + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +class GradeResponse(GradeBase): + id: str = Field(..., alias="_id") + created_at: datetime + updated_at: datetime + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +# Report Period Model +class ReportPeriodBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) # e.g., "First Half-Year 2024/2025" + start_date: datetime + end_date: datetime + + @field_validator("end_date") + @classmethod + def validate_dates(cls, end_date: datetime, info) -> datetime: + start_date = info.data.get("start_date") + if start_date and end_date <= start_date: + raise ValueError("end_date must be after start_date") + return end_date + + +class ReportPeriodCreate(ReportPeriodBase): + pass + + +class ReportPeriodUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + + +class ReportPeriodInDB(ReportPeriodBase): + id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") + user_id: str + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +class ReportPeriodResponse(ReportPeriodBase): + id: str = Field(..., alias="_id") + created_at: datetime + updated_at: datetime + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +# Teacher Grade Override Model (manual grades from teachers) +class TeacherGradeBase(BaseModel): + subject_id: str + period_id: str + grade: float = Field(..., ge=0) # The grade value (e.g., 1.0-6.0 for German, 0-100 for percentage) + max_grade: float = Field(..., ge=0) # Maximum possible grade (e.g., 6.0 or 100) + notes: Optional[str] = Field(None, max_length=500) + + +class TeacherGradeCreate(TeacherGradeBase): + pass + + +class TeacherGradeUpdate(BaseModel): + grade: Optional[float] = Field(None, ge=0) + max_grade: Optional[float] = Field(None, ge=0) + notes: Optional[str] = Field(None, max_length=500) + + +class TeacherGradeInDB(TeacherGradeBase): + id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") + user_id: str + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} + + +class TeacherGradeResponse(TeacherGradeBase): + id: str = Field(..., alias="_id") + created_at: datetime + updated_at: datetime + + class Config: + populate_by_name = True + json_encoders = {ObjectId: str} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7281bf2 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +motor==3.6.0 +pymongo>=4.9,<4.10 +pydantic==2.10.5 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.20 +email-validator==2.2.0 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..db55a6b --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1,7 @@ +# Backend routes +from . import auth_routes +from . import subject_routes +from . import grade_routes +from . import period_routes + +__all__ = ["auth_routes", "subject_routes", "grade_routes", "period_routes"] diff --git a/backend/routes/auth_routes.py b/backend/routes/auth_routes.py new file mode 100644 index 0000000..5bca242 --- /dev/null +++ b/backend/routes/auth_routes.py @@ -0,0 +1,149 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from datetime import timedelta +from typing import Optional +from models import UserCreate, UserLogin, UserResponse, UserInDB, Token +from auth import get_password_hash, verify_password, create_access_token, decode_access_token +from database import get_database +from config import settings +from bson import ObjectId + +router = APIRouter(prefix="/auth", tags=["authentication"]) +security = HTTPBearer() + + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInDB: + """Get current authenticated user""" + token = credentials.credentials + payload = decode_access_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + db = get_database() + user_dict = await db.users.find_one({"_id": ObjectId(user_id)}) + + if user_dict is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_dict["_id"] = str(user_dict["_id"]) + return UserInDB(**user_dict) + + +@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED) +async def register(user: UserCreate): + """Register a new user""" + db = get_database() + + # Check if user already exists + existing_user = await db.users.find_one({"email": user.email}) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + existing_username = await db.users.find_one({"username": user.username}) + if existing_username: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already taken" + ) + + # Create new user + user_in_db = UserInDB( + email=user.email, + username=user.username, + hashed_password=get_password_hash(user.password) + ) + + user_dict = user_in_db.model_dump(by_alias=True) + user_dict["_id"] = ObjectId(user_dict["_id"]) + + result = await db.users.insert_one(user_dict) + user_dict["_id"] = str(result.inserted_id) + + # Create access token + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": str(result.inserted_id)}, + expires_delta=access_token_expires + ) + + user_response = UserResponse( + _id=str(result.inserted_id), + email=user.email, + username=user.username, + created_at=user_in_db.created_at + ) + + return Token(access_token=access_token, user=user_response) + + +@router.post("/login", response_model=Token) +async def login(user_login: UserLogin): + """Login a user""" + db = get_database() + + # Find user by email + user_dict = await db.users.find_one({"email": user_login.email}) + if not user_dict: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_dict["_id"] = str(user_dict["_id"]) + user = UserInDB(**user_dict) + + # Verify password + if not verify_password(user_login.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Create access token + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": user.id}, + expires_delta=access_token_expires + ) + + user_response = UserResponse( + _id=user.id, + email=user.email, + username=user.username, + created_at=user.created_at + ) + + return Token(access_token=access_token, user=user_response) + + +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: UserInDB = Depends(get_current_user)): + """Get current user information""" + return UserResponse( + _id=current_user.id, + email=current_user.email, + username=current_user.username, + created_at=current_user.created_at + ) diff --git a/backend/routes/grade_routes.py b/backend/routes/grade_routes.py new file mode 100644 index 0000000..ed35796 --- /dev/null +++ b/backend/routes/grade_routes.py @@ -0,0 +1,202 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from typing import List, Optional +from models import GradeCreate, GradeUpdate, GradeResponse, GradeInDB, UserInDB +from database import get_database +from routes.auth_routes import get_current_user +from bson import ObjectId +from datetime import datetime + +router = APIRouter(prefix="/grades", tags=["grades"]) + + +@router.post("", response_model=GradeResponse, status_code=status.HTTP_201_CREATED) +async def create_grade( + grade: GradeCreate, + current_user: UserInDB = Depends(get_current_user) +): + """Create a new grade/test entry""" + db = get_database() + + # Verify subject exists and belongs to user + if not ObjectId.is_valid(grade.subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + + subject = await db.subjects.find_one({ + "_id": ObjectId(grade.subject_id), + "user_id": current_user.id + }) + + if not subject: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subject not found" + ) + + # Verify category exists in subject + category_names = [cat["name"] for cat in subject.get("grading_categories", [])] + if grade.category_name not in category_names: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Category '{grade.category_name}' not found in subject. Available: {', '.join(category_names)}" + ) + + grade_in_db = GradeInDB( + **grade.model_dump(), + user_id=current_user.id + ) + + grade_dict = grade_in_db.model_dump(by_alias=True) + grade_dict["_id"] = ObjectId(grade_dict["_id"]) + + result = await db.grades.insert_one(grade_dict) + grade_dict["_id"] = str(result.inserted_id) + + return GradeResponse(**grade_dict) + + +@router.get("", response_model=List[GradeResponse]) +async def get_grades( + subject_id: Optional[str] = Query(None), + current_user: UserInDB = Depends(get_current_user) +): + """Get all grades for the current user, optionally filtered by subject""" + db = get_database() + + query = {"user_id": current_user.id} + if subject_id: + if not ObjectId.is_valid(subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + query["subject_id"] = subject_id + + cursor = db.grades.find(query).sort("date", -1) # Most recent first + grades = await cursor.to_list(length=None) + + for grade in grades: + grade["_id"] = str(grade["_id"]) + + return [GradeResponse(**grade) for grade in grades] + + +@router.get("/{grade_id}", response_model=GradeResponse) +async def get_grade( + grade_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Get a specific grade""" + db = get_database() + + if not ObjectId.is_valid(grade_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid grade ID" + ) + + grade = await db.grades.find_one({ + "_id": ObjectId(grade_id), + "user_id": current_user.id + }) + + if not grade: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Grade not found" + ) + + grade["_id"] = str(grade["_id"]) + return GradeResponse(**grade) + + +@router.put("/{grade_id}", response_model=GradeResponse) +async def update_grade( + grade_id: str, + grade_update: GradeUpdate, + current_user: UserInDB = Depends(get_current_user) +): + """Update a grade""" + db = get_database() + + if not ObjectId.is_valid(grade_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid grade ID" + ) + + # Check if grade exists and belongs to user + existing_grade = await db.grades.find_one({ + "_id": ObjectId(grade_id), + "user_id": current_user.id + }) + + if not existing_grade: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Grade not found" + ) + + # If updating category, verify it exists in subject + if grade_update.category_name: + subject = await db.subjects.find_one({ + "_id": ObjectId(existing_grade["subject_id"]), + "user_id": current_user.id + }) + + if subject: + category_names = [cat["name"] for cat in subject.get("grading_categories", [])] + if grade_update.category_name not in category_names: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Category '{grade_update.category_name}' not found in subject" + ) + + # Update only provided fields + update_data = grade_update.model_dump(exclude_unset=True) + if update_data: + update_data["updated_at"] = datetime.utcnow() + await db.grades.update_one( + {"_id": ObjectId(grade_id)}, + {"$set": update_data} + ) + + # Fetch updated grade + updated_grade = await db.grades.find_one({"_id": ObjectId(grade_id)}) + updated_grade["_id"] = str(updated_grade["_id"]) + + return GradeResponse(**updated_grade) + + +@router.delete("/{grade_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_grade( + grade_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Delete a grade""" + db = get_database() + + if not ObjectId.is_valid(grade_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid grade ID" + ) + + # Check if grade exists and belongs to user + grade = await db.grades.find_one({ + "_id": ObjectId(grade_id), + "user_id": current_user.id + }) + + if not grade: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Grade not found" + ) + + # Delete grade + await db.grades.delete_one({"_id": ObjectId(grade_id)}) + + return None diff --git a/backend/routes/period_routes.py b/backend/routes/period_routes.py new file mode 100644 index 0000000..a0fd59a --- /dev/null +++ b/backend/routes/period_routes.py @@ -0,0 +1,149 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from typing import List +from models import ReportPeriodCreate, ReportPeriodUpdate, ReportPeriodResponse, ReportPeriodInDB, UserInDB +from database import get_database +from routes.auth_routes import get_current_user +from bson import ObjectId +from datetime import datetime + +router = APIRouter(prefix="/periods", tags=["report-periods"]) + + +@router.post("", response_model=ReportPeriodResponse, status_code=status.HTTP_201_CREATED) +async def create_period( + period: ReportPeriodCreate, + current_user: UserInDB = Depends(get_current_user) +): + """Create a new report period""" + db = get_database() + + period_in_db = ReportPeriodInDB( + **period.model_dump(), + user_id=current_user.id + ) + + period_dict = period_in_db.model_dump(by_alias=True) + period_dict["_id"] = ObjectId(period_dict["_id"]) + + result = await db.periods.insert_one(period_dict) + period_dict["_id"] = str(result.inserted_id) + + return ReportPeriodResponse(**period_dict) + + +@router.get("", response_model=List[ReportPeriodResponse]) +async def get_periods(current_user: UserInDB = Depends(get_current_user)): + """Get all report periods for the current user""" + db = get_database() + + cursor = db.periods.find({"user_id": current_user.id}).sort("start_date", -1) + periods = await cursor.to_list(length=None) + + for period in periods: + period["_id"] = str(period["_id"]) + + return [ReportPeriodResponse(**period) for period in periods] + + +@router.get("/{period_id}", response_model=ReportPeriodResponse) +async def get_period( + period_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Get a specific report period""" + db = get_database() + + if not ObjectId.is_valid(period_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period ID" + ) + + period = await db.periods.find_one({ + "_id": ObjectId(period_id), + "user_id": current_user.id + }) + + if not period: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Report period not found" + ) + + period["_id"] = str(period["_id"]) + return ReportPeriodResponse(**period) + + +@router.put("/{period_id}", response_model=ReportPeriodResponse) +async def update_period( + period_id: str, + period_update: ReportPeriodUpdate, + current_user: UserInDB = Depends(get_current_user) +): + """Update a report period""" + db = get_database() + + if not ObjectId.is_valid(period_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period ID" + ) + + # Check if period exists and belongs to user + existing_period = await db.periods.find_one({ + "_id": ObjectId(period_id), + "user_id": current_user.id + }) + + if not existing_period: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Report period not found" + ) + + # Update only provided fields + update_data = period_update.model_dump(exclude_unset=True) + if update_data: + update_data["updated_at"] = datetime.utcnow() + await db.periods.update_one( + {"_id": ObjectId(period_id)}, + {"$set": update_data} + ) + + # Fetch updated period + updated_period = await db.periods.find_one({"_id": ObjectId(period_id)}) + updated_period["_id"] = str(updated_period["_id"]) + + return ReportPeriodResponse(**updated_period) + + +@router.delete("/{period_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_period( + period_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Delete a report period""" + db = get_database() + + if not ObjectId.is_valid(period_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period ID" + ) + + # Check if period exists and belongs to user + period = await db.periods.find_one({ + "_id": ObjectId(period_id), + "user_id": current_user.id + }) + + if not period: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Report period not found" + ) + + # Delete period + await db.periods.delete_one({"_id": ObjectId(period_id)}) + + return None diff --git a/backend/routes/subject_routes.py b/backend/routes/subject_routes.py new file mode 100644 index 0000000..461423d --- /dev/null +++ b/backend/routes/subject_routes.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from typing import List +from models import SubjectCreate, SubjectUpdate, SubjectResponse, SubjectInDB, UserInDB +from database import get_database +from routes.auth_routes import get_current_user +from bson import ObjectId +from datetime import datetime + +router = APIRouter(prefix="/subjects", tags=["subjects"]) + + +@router.post("", response_model=SubjectResponse, status_code=status.HTTP_201_CREATED) +async def create_subject( + subject: SubjectCreate, + current_user: UserInDB = Depends(get_current_user) +): + """Create a new subject""" + db = get_database() + + subject_in_db = SubjectInDB( + **subject.model_dump(), + user_id=current_user.id + ) + + subject_dict = subject_in_db.model_dump(by_alias=True) + subject_dict["_id"] = ObjectId(subject_dict["_id"]) + + result = await db.subjects.insert_one(subject_dict) + subject_dict["_id"] = str(result.inserted_id) + + return SubjectResponse(**subject_dict) + + +@router.get("", response_model=List[SubjectResponse]) +async def get_subjects(current_user: UserInDB = Depends(get_current_user)): + """Get all subjects for the current user""" + db = get_database() + + cursor = db.subjects.find({"user_id": current_user.id}) + subjects = await cursor.to_list(length=None) + + for subject in subjects: + subject["_id"] = str(subject["_id"]) + + return [SubjectResponse(**subject) for subject in subjects] + + +@router.get("/{subject_id}", response_model=SubjectResponse) +async def get_subject( + subject_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Get a specific subject""" + db = get_database() + + if not ObjectId.is_valid(subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + + subject = await db.subjects.find_one({ + "_id": ObjectId(subject_id), + "user_id": current_user.id + }) + + if not subject: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subject not found" + ) + + subject["_id"] = str(subject["_id"]) + return SubjectResponse(**subject) + + +@router.put("/{subject_id}", response_model=SubjectResponse) +async def update_subject( + subject_id: str, + subject_update: SubjectUpdate, + current_user: UserInDB = Depends(get_current_user) +): + """Update a subject""" + db = get_database() + + if not ObjectId.is_valid(subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + + # Check if subject exists and belongs to user + existing_subject = await db.subjects.find_one({ + "_id": ObjectId(subject_id), + "user_id": current_user.id + }) + + if not existing_subject: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subject not found" + ) + + # Update only provided fields + update_data = subject_update.model_dump(exclude_unset=True) + if update_data: + update_data["updated_at"] = datetime.utcnow() + await db.subjects.update_one( + {"_id": ObjectId(subject_id)}, + {"$set": update_data} + ) + + # Fetch updated subject + updated_subject = await db.subjects.find_one({"_id": ObjectId(subject_id)}) + updated_subject["_id"] = str(updated_subject["_id"]) + + return SubjectResponse(**updated_subject) + + +@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_subject( + subject_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Delete a subject and all associated grades""" + db = get_database() + + if not ObjectId.is_valid(subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + + # Check if subject exists and belongs to user + subject = await db.subjects.find_one({ + "_id": ObjectId(subject_id), + "user_id": current_user.id + }) + + if not subject: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subject not found" + ) + + # Delete subject + await db.subjects.delete_one({"_id": ObjectId(subject_id)}) + + # Delete all grades for this subject + await db.grades.delete_many({"subject_id": subject_id, "user_id": current_user.id}) + + return None diff --git a/backend/routes/teacher_grade_routes.py b/backend/routes/teacher_grade_routes.py new file mode 100644 index 0000000..bcb3367 --- /dev/null +++ b/backend/routes/teacher_grade_routes.py @@ -0,0 +1,186 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from typing import List, Optional +from models import TeacherGradeCreate, TeacherGradeUpdate, TeacherGradeResponse, TeacherGradeInDB, UserInDB +from database import get_database +from routes.auth_routes import get_current_user +from bson import ObjectId +from datetime import datetime + +router = APIRouter(prefix="/teacher-grades", tags=["teacher-grades"]) + + +@router.post("", response_model=TeacherGradeResponse, status_code=status.HTTP_201_CREATED) +async def create_teacher_grade( + teacher_grade: TeacherGradeCreate, + current_user: UserInDB = Depends(get_current_user) +): + """Create or update a teacher-provided grade override for a subject in a period""" + db = get_database() + + # Verify subject exists and belongs to user + if not ObjectId.is_valid(teacher_grade.subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + + subject = await db.subjects.find_one({ + "_id": ObjectId(teacher_grade.subject_id), + "user_id": current_user.id + }) + + if not subject: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subject not found" + ) + + # Verify period exists and belongs to user + if not ObjectId.is_valid(teacher_grade.period_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period ID" + ) + + period = await db.periods.find_one({ + "_id": ObjectId(teacher_grade.period_id), + "user_id": current_user.id + }) + + if not period: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Period not found" + ) + + # Check if teacher grade already exists for this subject and period + existing = await db.teacher_grades.find_one({ + "subject_id": teacher_grade.subject_id, + "period_id": teacher_grade.period_id, + "user_id": current_user.id + }) + + if existing: + # Update existing teacher grade + update_data = teacher_grade.model_dump() + update_data["updated_at"] = datetime.utcnow() + + await db.teacher_grades.update_one( + {"_id": existing["_id"]}, + {"$set": update_data} + ) + + updated = await db.teacher_grades.find_one({"_id": existing["_id"]}) + updated["_id"] = str(updated["_id"]) + return TeacherGradeResponse(**updated) + else: + # Create new teacher grade + teacher_grade_in_db = TeacherGradeInDB( + **teacher_grade.model_dump(), + user_id=current_user.id + ) + + grade_dict = teacher_grade_in_db.model_dump(by_alias=True) + grade_dict["_id"] = ObjectId(grade_dict["_id"]) + + result = await db.teacher_grades.insert_one(grade_dict) + grade_dict["_id"] = str(result.inserted_id) + + return TeacherGradeResponse(**grade_dict) + + +@router.get("", response_model=List[TeacherGradeResponse]) +async def get_teacher_grades( + subject_id: Optional[str] = Query(None), + period_id: Optional[str] = Query(None), + current_user: UserInDB = Depends(get_current_user) +): + """Get all teacher grades for the current user, optionally filtered by subject or period""" + db = get_database() + + query = {"user_id": current_user.id} + + if subject_id: + if not ObjectId.is_valid(subject_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid subject ID" + ) + query["subject_id"] = subject_id + + if period_id: + if not ObjectId.is_valid(period_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period ID" + ) + query["period_id"] = period_id + + cursor = db.teacher_grades.find(query) + teacher_grades = await cursor.to_list(length=None) + + for grade in teacher_grades: + grade["_id"] = str(grade["_id"]) + + return [TeacherGradeResponse(**grade) for grade in teacher_grades] + + +@router.get("/{teacher_grade_id}", response_model=TeacherGradeResponse) +async def get_teacher_grade( + teacher_grade_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Get a specific teacher grade""" + db = get_database() + + if not ObjectId.is_valid(teacher_grade_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid teacher grade ID" + ) + + teacher_grade = await db.teacher_grades.find_one({ + "_id": ObjectId(teacher_grade_id), + "user_id": current_user.id + }) + + if not teacher_grade: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher grade not found" + ) + + teacher_grade["_id"] = str(teacher_grade["_id"]) + return TeacherGradeResponse(**teacher_grade) + + +@router.delete("/{teacher_grade_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_teacher_grade( + teacher_grade_id: str, + current_user: UserInDB = Depends(get_current_user) +): + """Delete a teacher grade override""" + db = get_database() + + if not ObjectId.is_valid(teacher_grade_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid teacher grade ID" + ) + + # Check if teacher grade exists and belongs to user + teacher_grade = await db.teacher_grades.find_one({ + "_id": ObjectId(teacher_grade_id), + "user_id": current_user.id + }) + + if not teacher_grade: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher grade not found" + ) + + # Delete teacher grade + await db.teacher_grades.delete_one({"_id": ObjectId(teacher_grade_id)}) + + return None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a78071 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + # Frontend Build Container (builds once and exits) + frontend-build: + image: node:20-alpine + container_name: grademaxxing-frontend-build + working_dir: /app + command: sh -c "corepack enable && pnpm install --frozen-lockfile && pnpm run build" + volumes: + - ./:/app + env_file: + - ./.env + environment: + - CI=true + networks: + - grademaxxing-network + + # Python Backend (serves API and static files) + backend: + image: python:3.9 + container_name: grademaxxing-backend + restart: unless-stopped + working_dir: /app + command: sh -c "cd backend && pip install --no-cache-dir -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000" + ports: + - "4442:8000" + volumes: + - .:/app + env_file: + - ./.env + networks: + - grademaxxing-network + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + grademaxxing-network: + driver: bridge diff --git a/package.json b/package.json new file mode 100644 index 0000000..867cf66 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "grademaxxing", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "preview": "vite preview --outDir build/client", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@react-router/node": "7.12.0", + "@react-router/serve": "7.12.0", + "isbot": "^5.1.31", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router": "7.12.0" + }, + "devDependencies": { + "@react-router/dev": "7.12.0", + "@tailwindcss/vite": "^4.1.13", + "@types/node": "^22", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..41238c5 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2567 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-router/node': + specifier: 7.12.0 + version: 7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@react-router/serve': + specifier: 7.12.0 + version: 7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + isbot: + specifier: ^5.1.31 + version: 5.1.32 + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + react-router: + specifier: 7.12.0 + version: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + devDependencies: + '@react-router/dev': + specifier: 7.12.0 + version: 7.12.0(@react-router/serve@7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)) + '@tailwindcss/vite': + specifier: ^4.1.13 + version: 4.1.18(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)) + '@types/node': + specifier: ^22 + version: 22.19.6 + '@types/react': + specifier: ^19.2.7 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + tailwindcss: + specifier: ^4.1.13 + version: 4.1.18 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vite: + specifier: ^7.1.7 + version: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)) + +packages: + + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mjackson/node-fetch-server@0.2.0': + resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} + + '@react-router/dev@7.12.0': + resolution: {integrity: sha512-5GpwXgq4pnOVeG7l6ADkCHA1rthJus1q/A3NRYJAIypclUQDYAzg1/fDNjvaKuTSrq+Nr3u6aj2v+oC+47MX6g==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@react-router/serve': ^7.12.0 + '@vitejs/plugin-rsc': ~0.5.7 + react-router: ^7.12.0 + react-server-dom-webpack: ^19.2.3 + typescript: ^5.1.0 + vite: ^5.1.0 || ^6.0.0 || ^7.0.0 + wrangler: ^3.28.2 || ^4.0.0 + peerDependenciesMeta: + '@react-router/serve': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-webpack: + optional: true + typescript: + optional: true + wrangler: + optional: true + + '@react-router/express@7.12.0': + resolution: {integrity: sha512-uAK+zF93M6XauGeXLh/UBh+3HrwiA/9lUS+eChjQ0a5FzjLpsc6ciUqF5oHh3lwWzLU7u7tj4qoeucUn6SInTw==} + engines: {node: '>=20.0.0'} + peerDependencies: + express: ^4.17.1 || ^5 + react-router: 7.12.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@react-router/node@7.12.0': + resolution: {integrity: sha512-o/t10Cse4LK8kFefqJ8JjC6Ng6YuKD2I87S2AiJs17YAYtXU5W731ZqB73AWyCDd2G14R0dSuqXiASRNK/xLjg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react-router: 7.12.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@react-router/serve@7.12.0': + resolution: {integrity: sha512-j1ltgU7s3wAwOosZ5oxgHSsmVyK706gY/yIs8qVmC239wQ3zr3eqaXk3TVVLMeRy+eDgPNmgc6oNJv2o328VgA==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + react-router: 7.12.0 + + '@remix-run/node-fetch-server@0.9.0': + resolution: {integrity: sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==} + + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.6': + resolution: {integrity: sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.8': + resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + baseline-browser-mapping@2.9.14: + resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001764: + resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + isbot@5.1.32: + resolution: {integrity: sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ==} + engines: {node: '>=18'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.8.0: + resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-router@7.12.0: + resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.6': {} + + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.0.2 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.6 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.6) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.6 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.28.6 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.6) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.6) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.28.6) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mjackson/node-fetch-server@0.2.0': {} + + '@react-router/dev@7.12.0(@react-router/serve@7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@babel/core': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.6) + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@react-router/node': 7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@remix-run/node-fetch-server': 0.9.0 + arg: 5.0.2 + babel-dead-code-elimination: 1.0.12 + chokidar: 4.0.3 + dedent: 1.7.1 + es-module-lexer: 1.7.0 + exit-hook: 2.2.1 + isbot: 5.1.32 + jsesc: 3.0.2 + lodash: 4.17.21 + p-map: 7.0.4 + pathe: 1.1.2 + picocolors: 1.1.1 + pkg-types: 2.3.0 + prettier: 3.8.0 + react-refresh: 0.14.2 + react-router: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + semver: 7.7.3 + tinyglobby: 0.2.15 + valibot: 1.2.0(typescript@5.9.3) + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2) + vite-node: 3.2.4(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2) + optionalDependencies: + '@react-router/serve': 7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@react-router/express@7.12.0(express@4.22.1)(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@react-router/node': 7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + express: 4.22.1 + react-router: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + typescript: 5.9.3 + + '@react-router/node@7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@mjackson/node-fetch-server': 0.2.0 + react-router: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + typescript: 5.9.3 + + '@react-router/serve@7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@mjackson/node-fetch-server': 0.2.0 + '@react-router/express': 7.12.0(express@4.22.1)(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@react-router/node': 7.12.0(react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + compression: 1.8.1 + express: 4.22.1 + get-port: 5.1.1 + morgan: 1.10.1 + react-router: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + source-map-support: 0.5.21 + transitivePeerDependencies: + - supports-color + - typescript + + '@remix-run/node-fetch-server@0.9.0': {} + + '@rollup/rollup-android-arm-eabi@4.55.1': + optional: true + + '@rollup/rollup-android-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-x64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.55.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.55.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.55.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.55.1': + optional: true + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2) + + '@types/estree@1.0.8': {} + + '@types/node@22.19.6': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.8)': + dependencies: + '@types/react': 19.2.8 + + '@types/react@19.2.8': + dependencies: + csstype: 3.2.3 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + arg@5.0.2: {} + + array-flatten@1.1.1: {} + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.9.14: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.14 + caniuse-lite: 1.0.30001764 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001764: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + confbox@0.2.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.1: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.267: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.18.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + exit-hook@2.2.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.8: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-port@5.1.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + isbot@5.1.32: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.0.2: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lodash@4.17.21: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + node-releases@2.0.27: {} + + object-inspect@1.13.4: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + p-map@7.0.4: {} + + parseurl@1.3.3: {} + + path-to-regexp@0.1.12: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.8.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-refresh@0.14.2: {} + + react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + react@19.2.3: {} + + readdirp@4.1.2: {} + + rollup@4.55.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + toidentifier@1.0.1: {} + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + utils-merge@1.0.1: {} + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - supports-color + - typescript + + vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.6 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + + yallist@3.1.1: {} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/react-router.config.ts b/react-router.config.ts new file mode 100644 index 0000000..b8b143a --- /dev/null +++ b/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + // Config options... + // Server-side render by default, to enable SPA mode set this to `false` + ssr: false, +} satisfies Config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dc391a4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4a88d58 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,8 @@ +import { reactRouter } from "@react-router/dev/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], +});