feat: unify real activity tracking, admin monitoring, and error UX

This commit is contained in:
Stalin
2026-04-19 17:50:53 +05:30
parent cfc159d105
commit 9115fc5ffd
86 changed files with 9002 additions and 2838 deletions
+312
View File
@@ -0,0 +1,312 @@
# OpenLearnX - Complete Deployment Summary
**Deployment Date:** April 19, 2026
**Status:** ✅ FULLY DEPLOYED AND RUNNING
---
## 🚀 Services Running
### 1. **Backend API (Flask)**
- **Status:** ✅ Running
- **Port:** 5000
- **URL:** http://localhost:5000/api
- **Endpoints Available:**
- /api/auth (Authentication)
- /api/test (Testing)
- /api/certificate (NFT Certificates)
- /api/dashboard (Analytics Dashboard)
- /api/courses (Course Management)
- /api/quizzes (Quiz System)
- /api/admin (Admin Panel)
- /api/exam (Exam System)
- /api/adaptive-quiz (Adaptive Learning)
- **Database:** MongoDB (localhost:27017)
- **Health Check:** `curl http://localhost:5000/api/health`
### 2. **Frontend (Next.js)**
- **Status:** ✅ Running
- **Port:** 3000
- **URL:** http://localhost:3000
- **Framework:** Next.js 16.1.6
- **Package Manager:** pnpm 10.33.0
- **Features:**
- Real-time Dashboard
- Quiz Interface
- Course Viewer
- Certificate Display
- User Authentication
- **Environment:** Development mode with hot reload
### 3. **Database (MongoDB)**
- **Status:** ✅ Running
- **Port:** 27017
- **Version:** 7.0.14
- **URI:** mongodb://localhost:27017/openlearnx
- **Collections:**
- users
- courses
- quizzes
- certificates
- user_achievements
- user_stats
- user_submissions
### 4. **Blockchain (Anvil - Ethereum)**
- **Status:** ✅ Running
- **Port:** 8545
- **Chain ID:** 31337 (Local test network)
- **RPC URL:** http://127.0.0.1:8545
- **Test Accounts:** 10 accounts with 10,000 ETH each
### 5. **Smart Contracts**
- **Status:** ✅ Deployed
- **Contract:** CertificateNFT.sol
- **Address:** 0x5FbDB2315678afecb367f032d93F642f64180aa3
- **Network:** Local Anvil (Chain ID: 31337)
- **Gas Used:** 3,391,283
- **Block:** 1
---
## 📁 Project Structure
```
OpenLearnX/
├── backend/ # Flask API + Smart Contracts
│ ├── main.py # Main application
│ ├── routes/ # API endpoints
│ ├── services/ # Business logic
│ ├── models/ # Data models
│ ├── contracts/ # Solidity files
│ ├── .env # Configuration (configured)
│ └── venv_openlearnx/ # Python virtual environment
├── frontend/ # Next.js React application
│ ├── app/ # Next.js app directory
│ ├── components/ # React components
│ ├── .env.local # Frontend config (configured)
│ └── node_modules/ # Dependencies
├── venv_openlearnx/ # Backend venv
└── deployment.json # Smart contract deployment info
```
---
## 🔧 Environment Configuration
### Backend (.env)
```
FLASK_ENV=development
SECRET_KEY=dev-secret-key-change-in-production
MONGODB_URI=mongodb://localhost:27017/openlearnx
WEB3_PROVIDER_URL=http://127.0.0.1:8545
CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
JWT_SECRET_KEY=jwt-secret-key-do-change
FLASK_DEBUG=True
DEPLOYER_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb476c6b8d6c1f02960247590bccf
```
### Frontend (.env.local)
```
NEXT_PUBLIC_API_URL=http://localhost:5000/api
NEXT_PUBLIC_WEB3_PROVIDER_URL=http://127.0.0.1:8545
```
---
## 📊 System Information
| Component | Version | Status |
|-----------|---------|--------|
| Node.js | 22.22.2 | ✅ |
| npm | 9.2.0 | ✅ |
| Python | 3.13.12 | ✅ |
| Foundry | 1.5.1-stable | ✅ |
| MongoDB | 7.0.14 | ✅ |
| Flask | 3.1.3 | ✅ |
| Web3.py | 7.15.0 | ✅ |
| PyMongo | 4.16.0 | ✅ |
| Next.js | 16.1.6 | ✅ |
| React | 19.1.0 | ✅ |
| TypeScript | 5.8.3 | ✅ |
---
## 🌐 Access URLs
| Service | URL | Status |
|---------|-----|--------|
| Frontend | http://localhost:3000 | ✅ |
| Backend API | http://localhost:5000/api | ✅ |
| Backend Health | http://localhost:5000/api/health | ✅ |
| MongoDB | localhost:27017 | ✅ |
| Ethereum RPC | http://localhost:8545 | ✅ |
---
## ✨ Features Deployed
### ✅ Implemented & Running
- User Authentication (JWT)
- Course Management
- Quiz System
- Adaptive Learning Engine
- Certificate Generation (ERC-721 NFTs)
- Dashboard & Analytics
- Test/Exam System
- User Progress Tracking
- MongoDB Data Persistence
- Web3 Integration
- MetaMask Support (Frontend ready)
### ⚠️ Optional Features (Non-Critical)
- Docker Compiler (requires Docker installation)
- AI Quiz Service (requires TensorFlow)
- Advanced ML Models
---
## 🔌 Running Services Command Reference
### Start Backend
```bash
cd backend
source ../venv_openlearnx/bin/activate
python3 main.py
```
### Start Frontend
```bash
cd frontend
pnpm dev
```
### Start Blockchain
```bash
anvil
```
### Check Service Health
```bash
# Backend
curl http://localhost:5000/api/health
# Frontend
curl -I http://localhost:3000
# MongoDB
mongosh --eval "db.adminCommand('ping')"
# Anvil
curl -s -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
```
---
## 📋 Deployment Checklist
- ✅ Install prerequisites (Node.js, Python, Foundry)
- ✅ Clone repository
- ✅ Create Python virtual environment
- ✅ Install Python dependencies
- ✅ Install Node.js dependencies (pnpm)
- ✅ Configure MongoDB
- ✅ Configure Flask backend
- ✅ Configure Next.js frontend
- ✅ Compile smart contracts (Forge)
- ✅ Deploy smart contracts (Anvil)
- ✅ Start MongoDB
- ✅ Start Anvil
- ✅ Start Flask backend
- ✅ Start Next.js frontend
- ✅ Verify all services
- ✅ Test API endpoints
- ✅ Test frontend connectivity
---
## 🚨 Known Issues & Notes
1. **Docker Compiler** - Optional feature, requires Docker installation
2. **AI Quiz Service** - Optional feature, requires TensorFlow (conflicts with Python 3.13)
3. **ESLint Config** - Non-critical warning in Next.js
4. **Development Secrets** - Change SECRET_KEY and JWT_SECRET_KEY in production
5. **CORS** - Configure CORS properly for production
---
## 🔐 Security Notes
⚠️ **FOR DEVELOPMENT ONLY**
In production, you must:
1. Change SECRET_KEY to a secure random value
2. Change JWT_SECRET_KEY to a secure random value
3. Use real database with authentication
4. Deploy to secure network/cloud
5. Use HTTPS instead of HTTP
6. Configure CORS for specific domains
7. Use production database credentials
8. Remove or restrict admin endpoints
9. Implement rate limiting
10. Enable security headers
---
## 📞 Support & Troubleshooting
### Services Not Starting?
1. Check ports are available: `lsof -i -P -n | grep LISTEN`
2. Verify MongoDB is running: `ps aux | grep mongod`
3. Check Python virtual environment: `source venv_openlearnx/bin/activate`
4. Verify Node.js installation: `node --version`
### Port Already in Use?
```bash
# Kill process on specific port
lsof -ti:5000 | xargs kill -9 # Backend
lsof -ti:3000 | xargs kill -9 # Frontend
lsof -ti:8545 | xargs kill -9 # Anvil
```
### Database Connection Issues?
```bash
# Test MongoDB
mongosh --eval "db.adminCommand('ping')"
# Check MongoDB status
sudo systemctl status mongod
sudo systemctl start mongod
```
---
## 📈 Performance Notes
- **Backend Response Time:** <100ms (typical)
- **Frontend Build:** ~1-2 seconds
- **Database Queries:** <50ms (typical)
- **Smart Contract Deployment:** ~5 seconds
- **Total Startup Time:** ~30 seconds (all services)
---
## 🎉 Deployment Complete!
Your OpenLearnX platform is now fully deployed and ready for development and testing!
**Next Steps:**
1. Access frontend at http://localhost:3000
2. Create test users via /api/auth endpoints
3. Add courses via /api/courses
4. Test quiz system
5. Deploy test NFT certificates
6. Monitor dashboard analytics
Enjoy! 🚀
---
*Generated: 2026-04-19 | OpenLearnX v5.0.0*
+329
View File
@@ -0,0 +1,329 @@
# Frontend Styling Fixes - Complete Summary
## Overview
Fixed styling consistency across all frontend pages to ensure:
- ✅ Consistent background gradients on all pages
- ✅ Dark mode (dark:) classes on every element
- ✅ Form inputs with visible text in both light and dark modes
- ✅ Uniform color scheme (blues and purples)
- ✅ All pages styled similar to the Dashboard
---
## Standard Styling Applied
### Container Backgrounds
**Light Mode:** `bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100`
**Dark Mode:** `dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900`
### Text Colors
**Light Mode:** `text-gray-900`
**Dark Mode:** `dark:text-white`
### Card Styling
**Light Mode:** `bg-white`
**Dark Mode:** `dark:bg-gray-800`
### Form Inputs
**Light Mode:** `bg-white text-gray-900 placeholder-gray-500 border border-gray-300`
**Dark Mode:** `dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:border-gray-600`
### Buttons
All buttons now include dark mode hover variants:
**Dark Mode:** `dark:hover:bg-{color}-800 dark:bg-{color}-700`
---
## Files Modified
### 1. `/frontend/app/quizzes/page.tsx`
**Issues Fixed:**
- ✅ Replaced `bg-gray-900 text-white` with standard gradient
- ✅ Updated tab buttons with dark mode classes
- ✅ Fixed text colors for light/dark modes
- ✅ Updated card styling for dark mode
**Before:**
```tsx
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="max-w-7xl mx-auto p-6">
<h1 className="text-4xl font-bold mb-4 flex items-center justify-center space-x-3">
<Trophy className="h-10 w-10 text-yellow-400" />
<span>🧠 OpenLearnX Quiz Platform</span>
</h1>
<p className="text-gray-400 max-w-2xl mx-auto">
```
**After:**
```tsx
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="max-w-7xl mx-auto p-6">
<h1 className="text-4xl font-bold mb-4 flex items-center justify-center space-x-3 text-gray-900 dark:text-white">
<Trophy className="h-10 w-10 text-yellow-400" />
<span>🧠 OpenLearnX Quiz Platform</span>
</h1>
<p className="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
```
---
### 2. `/frontend/app/quizzes/create/page.tsx`
**Issues Fixed:**
- ✅ Replaced `bg-gray-900 text-white` with standard gradient
- ✅ Fixed all input fields with proper dark mode colors
- ✅ Updated card styling with borders and shadows
- ✅ Fixed buttons with dark mode variants
**Before:**
```tsx
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* ... */}
<div className="bg-gray-800 p-6 rounded-lg mb-6">
<h2 className="text-xl font-bold mb-4">Quiz Information</h2>
<input
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
```
**After:**
```tsx
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
{/* ... */}
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Quiz Information</h2>
<input
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
```
---
### 3. `/frontend/app/quiz-join/page.tsx`
**Issues Fixed:**
- ✅ Replaced `bg-gray-900 text-white` with standard gradient
- ✅ Fixed username input with proper dark mode support
- ✅ Updated join mode toggle with dark mode classes
- ✅ Fixed text colors throughout
**Before:**
```tsx
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="text-center mb-8">
<Users className="h-16 w-16 text-blue-400 mx-auto mb-4" />
<h1 className="text-4xl font-bold mb-4">🎯 Join Quiz</h1>
<p className="text-gray-400">Join an adaptive quiz...</p>
```
**After:**
```tsx
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="text-center mb-8">
<Users className="h-16 w-16 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">🎯 Join Quiz</h1>
<p className="text-gray-600 dark:text-gray-300">Join an adaptive quiz...</p>
```
---
### 4. `/frontend/app/quiz-host/page.tsx`
**Issues Fixed:**
- ✅ Replaced `bg-gray-900 text-white` with standard gradient
- ✅ Fixed all form inputs with dark mode colors
- ✅ Updated checkbox styling with dark mode
- ✅ Fixed button gradients with dark mode variants
**Before:**
```tsx
if (!currentRoom) {
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="bg-gray-800 p-6 rounded-lg">
<input
className="w-full p-3 bg-gray-700 rounded border border-gray-600"
```
**After:**
```tsx
if (!currentRoom) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 shadow">
<input
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600"
```
---
### 5. `/frontend/app/compiler/page.tsx`
**Issues Fixed:**
- ✅ Replaced `bg-gray-900 text-white` with standard gradient
- ✅ Fixed header with white background and dark mode
- ✅ Updated language selector styling
- ✅ Fixed button colors with dark mode variants
**Before:**
```tsx
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="bg-gray-800 border-b border-gray-700 p-4">
<h1 className="text-2xl font-bold">OpenLearnX Real Compiler</h1>
<p className="text-gray-400">Execute code...</p>
<div className="bg-gray-800 rounded-lg p-4">
<select className="bg-gray-700 text-white px-3 py-1 rounded border border-gray-600">
```
**After:**
```tsx
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 shadow">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OpenLearnX Real Compiler</h1>
<p className="text-gray-600 dark:text-gray-400">Execute code...</p>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 shadow">
<select className="bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white px-3 py-1 rounded border border-gray-300 dark:border-gray-600">
```
---
### 6. `/frontend/app/admin/page.tsx`
**Issues Fixed:**
- ✅ Added standard gradient background
- ✅ Updated header with white background and dark mode
- ✅ Added dark mode to all text elements
- ✅ Fixed status badges with dark mode colors
**Before:**
```tsx
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white shadow border-b">
<h1 className="text-xl font-bold text-gray-900">OpenLearnX Admin Panel</h1>
<span className="bg-green-100 text-green-800 px-2 py-1">DYNAMIC</span>
```
**After:**
```tsx
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
<div className="bg-white dark:bg-gray-800 shadow border-b border-gray-200 dark:border-gray-700">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">OpenLearnX Admin Panel</h1>
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1">DYNAMIC</span>
```
---
### 7. `/frontend/app/auth/login/page.tsx`
**Status:** ✅ Already had good dark mode support
- Dark mode classes already present on main gradient
- Button styling already included dark variants
- Input components use Tailwind CSS variables that support dark mode
**Note:** Card components inherit dark mode from Tailwind config
---
### 8. `/frontend/app/auth/signup/page.tsx`
**Status:** ✅ Already had good dark mode support
- Dark mode classes already present on main gradient
- Button styling already included dark variants
- Input components properly styled with dark mode
---
### 9. `/frontend/app/coding/page.tsx`
**Status:** ✅ Already properly styled
- Unique animated design with light card backgrounds
- Inputs already have `text-gray-900` color
- Page design intentionally uses animated backgrounds for visual interest
- No changes needed - follows the same color standards
---
## Summary of Changes
| File | Background | Inputs | Buttons | Text Colors | Cards |
|------|-----------|--------|---------|-------------|-------|
| quizzes/page.tsx | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed |
| quizzes/create/page.tsx | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed |
| quiz-join/page.tsx | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed |
| quiz-host/page.tsx | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed |
| compiler/page.tsx | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed | ✅ Fixed |
| admin/page.tsx | ✅ Fixed | N/A | ✅ Fixed | ✅ Fixed | ✅ Fixed |
| auth/login/page.tsx | ✅ Already Good | ✅ Already Good | ✅ Already Good | ✅ Already Good | ✅ Already Good |
| auth/signup/page.tsx | ✅ Already Good | ✅ Already Good | ✅ Already Good | ✅ Already Good | ✅ Already Good |
| coding/page.tsx | ✅ Already Good | ✅ Already Good | ✅ Already Good | ✅ Already Good | ✅ Already Good |
---
## Testing Dark Mode
To test the dark mode changes:
1. **In browser DevTools:**
- Open DevTools → Right-click on `<html>` element
- Add `class="dark"` to the html tag
- All pages should display properly with dark background and light text
2. **With theme toggle (if available):**
- Look for theme toggle in navbar
- Pages should seamlessly switch between light and dark modes
3. **Verify:**
- Background gradients display correctly
- All text is readable in both modes
- Form inputs show text clearly in both modes
- Buttons are clickable and properly styled
- Cards have proper contrast
---
## Implementation Notes
### Input Field Pattern
All inputs now follow this pattern:
```tsx
<input
className="bg-white dark:bg-gray-700
text-gray-900 dark:text-white
placeholder-gray-500 dark:placeholder-gray-400
border border-gray-300 dark:border-gray-600"
/>
```
### Card Pattern
All cards now follow this pattern:
```tsx
<div className="bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg shadow">
```
### Text Pattern
All text now includes dark mode:
```tsx
<p className="text-gray-900 dark:text-white">
<span className="text-gray-600 dark:text-gray-300">
```
### Button Pattern
All buttons now include dark hover variants:
```tsx
<button className="bg-blue-600 dark:bg-blue-700
hover:bg-blue-700 dark:hover:bg-blue-800">
```
---
## Files Successfully Modified: 6
- ✅ /frontend/app/quizzes/page.tsx
- ✅ /frontend/app/quizzes/create/page.tsx
- ✅ /frontend/app/quiz-join/page.tsx
- ✅ /frontend/app/quiz-host/page.tsx
- ✅ /frontend/app/compiler/page.tsx
- ✅ /frontend/app/admin/page.tsx
**Status:** All styling fixes completed and verified! 🎉
+107
View File
@@ -0,0 +1,107 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import jwt
def _decode_token_unverified(token: str) -> Dict[str, Any]:
try:
return jwt.decode(
token,
options={"verify_signature": False},
algorithms=["HS256", "RS256"],
)
except Exception:
return {}
def resolve_user_identity(request, db=None) -> Dict[str, Optional[str]]:
"""Best-effort identity resolution from auth header, headers, payload, and optional DB lookup."""
token = None
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
payload = _decode_token_unverified(token) if token else {}
request_json = request.get_json(silent=True) or {}
user_id = (
payload.get("user_id")
or payload.get("sub")
or payload.get("uid")
or request.headers.get("X-User-ID")
or request.args.get("user_id")
or request_json.get("user_id")
)
wallet_address = (
payload.get("wallet_address")
or request.headers.get("X-Wallet-Address")
or request.args.get("wallet_address")
or request_json.get("wallet_address")
)
email = (
payload.get("email")
or request.headers.get("X-User-Email")
or request.args.get("email")
or request_json.get("email")
)
# Prefer wallet as canonical identity when available.
if wallet_address:
wallet_address = str(wallet_address).lower().strip()
user_id = wallet_address
# If only email is known and DB exists, resolve to canonical user id.
if not user_id and email and db is not None:
user = db.users.find_one({"email": str(email).lower().strip()})
if user:
if user.get("wallet_address"):
user_id = str(user.get("wallet_address")).lower().strip()
wallet_address = user_id
elif user.get("_id"):
user_id = str(user.get("_id"))
if user_id:
user_id = str(user_id).strip()
return {
"user_id": user_id,
"wallet_address": wallet_address,
"email": str(email).lower().strip() if email else None,
}
def log_user_activity(
db,
user_id: Optional[str],
activity_type: str,
title: str,
description: str,
metadata: Optional[Dict[str, Any]] = None,
points_earned: int = 0,
) -> bool:
"""Write a user activity event. Returns True on success, False otherwise."""
if not user_id:
return False
now_utc = datetime.now(timezone.utc)
doc = {
"user_id": str(user_id),
"type": activity_type,
"title": title,
"description": description,
"occurred_at": now_utc,
"completed_at": now_utc,
"timestamp_utc": now_utc.strftime("%Y-%m-%d %H:%M:%S UTC"),
"points_earned": int(points_earned or 0),
"metadata": metadata or {},
"source": "user_activity_events",
}
try:
db.user_activity_events.insert_one(doc)
return True
except Exception:
return False
File diff suppressed because one or more lines are too long
+5 -6
View File
@@ -1,8 +1,8 @@
{
"contract_address": "0xC2FE2F49B3a1384aEdFAae127F054FAf216eF684",
"transaction_hash": "0xfe5a433dae316bd2d60b7190c21866a1fde30777f08d9d37e403ed642433fa28",
"contract_address": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"transaction_hash": "973fa79fea65613ef2ccbb35d72ee0cabee2cf3a5bc834a9dc439fef544ace7d",
"deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"network": "local",
"network": "anvil",
"abi": [
{
"type": "constructor",
@@ -684,7 +684,6 @@
"anonymous": false
}
],
"gas_used": 3387337,
"block_number": 22994809,
"status": 1
"gas_used": 3391283,
"block_number": 1
}
+299 -8
View File
@@ -5,7 +5,7 @@ import uuid
import random
import string
from datetime import datetime, timedelta
from flask import Flask, jsonify, request
from flask import Flask, jsonify, request, make_response, g
from flask_cors import CORS
from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity, create_access_token
from dotenv import load_dotenv
@@ -21,6 +21,14 @@ from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
import secrets
import re
import json
import jwt as pyjwt
try:
import psutil
except Exception:
psutil = None
# Load environment variables
load_dotenv()
@@ -211,29 +219,312 @@ app.config.update(
# ✅ Initialize JWT with your configuration
jwt = JWTManager(app)
# ✅ ENHANCED CORS configuration for professional dashboard
# ✅ ENHANCED CORS configuration - Allow all localhost ports for development
CORS(app, resources={r"/api/*": {
"origins": [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:3002",
"http://localhost:3003",
"http://localhost:3004",
"http://localhost:3005",
"http://localhost:3006",
"http://127.0.0.1:3000",
"http://localhost:3001", # Development
"https://openlearnx.vercel.app" # Production (if deployed)
"http://127.0.0.1:3001",
"http://127.0.0.1:3002",
"http://127.0.0.1:3003",
"http://127.0.0.1:3004",
"http://127.0.0.1:3005",
"http://127.0.0.1:3006",
"https://openlearnx.vercel.app"
],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"],
"allow_headers": [
"Content-Type",
"Authorization",
"Accept",
"Origin",
"X-Requested-With",
"X-User-ID", # Custom header for user identification
"X-User-ID",
"X-Session-Token",
"X-Firebase-Token" # Firebase authentication
"X-Firebase-Token"
],
"expose_headers": ["Authorization", "X-Total-Count", "X-Rate-Limit", "Content-Type"],
"supports_credentials": True,
"expose_headers": ["Authorization", "X-Total-Count", "X-Rate-Limit"]
"max_age": 3600
}})
# ✅ Handle CORS preflight requests with explicit route
@app.before_request
def handle_preflight():
if request.method == "OPTIONS":
response = make_response()
response.headers.add("Access-Control-Allow-Origin", request.headers.get("Origin", "*"))
response.headers.add("Access-Control-Allow-Headers", "Content-Type,Authorization,Accept,Origin,X-Requested-With")
response.headers.add("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD")
response.headers.add("Access-Control-Max-Age", "3600")
response.headers.add("Access-Control-Allow-Credentials", "true")
return response, 200
SUSPICIOUS_PAYLOAD_PATTERNS = [
re.compile(r"(<script|javascript:|onerror=|onload=)", re.IGNORECASE),
re.compile(r"(\$where|\$regex|\$ne|\$gt|\$lt|\$or)", re.IGNORECASE),
re.compile(r"(union\s+select|drop\s+table|insert\s+into|delete\s+from)", re.IGNORECASE),
re.compile(r"(\.\./|%2e%2e%2f|/etc/passwd)", re.IGNORECASE)
]
def _detect_suspicious_payload(payload_text):
if not payload_text:
return []
matches = []
for pattern in SUSPICIOUS_PAYLOAD_PATTERNS:
if pattern.search(payload_text):
matches.append(pattern.pattern)
return matches
def _infer_event_type(path, method, status_code, suspicious=False):
if suspicious:
return "suspicious_payload"
if status_code == 403:
return "forbidden_access"
if "/api/auth/register" in path:
return "signup"
if "/api/auth/login" in path or "/api/auth/verify" in path or "/api/auth/wallet-login" in path:
return "signin"
if "/api/admin" in path:
return "admin_panel"
if "join" in path or "enroll" in path:
return "course_join"
if "attend" in path:
return "attendance"
if method == "GET":
return "page_visit"
return "api_activity"
def _firewall_rule_matches(rule, ip, method, path):
rule_ip = (rule.get("ip") or "").strip()
rule_method = (rule.get("method") or "").strip().upper()
path_pattern = (rule.get("path_pattern") or "").strip()
if rule_ip and rule_ip != ip:
return False
if rule_method and rule_method != method.upper():
return False
if path_pattern and path_pattern not in path:
return False
return True
@app.before_request
def enforce_manual_firewall_rules():
"""Apply admin-defined firewall rules only when manually configured."""
if request.method == "OPTIONS":
return None
# Keep firewall rule management reachable so admins can recover from bad rules.
if request.path.startswith("/api/admin/firewall"):
return None
try:
db = get_db()
if db is None:
return None
ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() if request.headers.get("X-Forwarded-For") else (request.remote_addr or "unknown")
method = request.method
path = request.path
rules = list(db.firewall_rules.find({"enabled": True}).sort("created_at", 1))
for rule in rules:
if not _firewall_rule_matches(rule, ip, method, path):
continue
action = (rule.get("action") or "block").strip().lower()
if action == "allow":
return None
# Manual block rule matched.
db.security_logs.insert_one({
"timestamp": datetime.utcnow(),
"event_type": "firewall_block",
"action": f"{method} {path}",
"status_code": 403,
"severity": "warning",
"path": path,
"method": method,
"ip": ip,
"user_agent": request.headers.get("User-Agent", ""),
"metadata": {
"rule_id": str(rule.get("_id")),
"rule_name": rule.get("name", ""),
"reason": "manual_firewall_rule"
}
})
return jsonify({"error": "Blocked by firewall rule"}), 403
except Exception as e:
logger.debug(f"Firewall check skipped: {e}")
return None
@app.before_request
def capture_request_start_and_payload():
g.request_start_time = time.time()
g.suspicious_matches = []
if request.method == "OPTIONS":
return None
try:
raw_payload = request.get_data(cache=True, as_text=True)[:4000]
except Exception:
raw_payload = ""
request_json = None
try:
request_json = request.get_json(silent=True)
except Exception:
request_json = None
request_form = {}
try:
request_form = {k: request.form.get(k) for k in request.form.keys()}
except Exception:
request_form = {}
request_headers = {}
try:
for key, value in request.headers.items():
lower = key.lower()
if lower in {"authorization", "cookie", "set-cookie"}:
request_headers[key] = "[redacted]"
else:
request_headers[key] = value
except Exception:
request_headers = {}
g.request_payload_preview = raw_payload
g.request_json_payload = request_json
g.request_form_payload = request_form
g.request_headers_snapshot = request_headers
g.suspicious_matches = _detect_suspicious_payload(raw_payload)
@app.after_request
def write_request_audit_log(response):
try:
db = get_db()
if db is None:
return response
start = getattr(g, "request_start_time", None)
duration_ms = int((time.time() - start) * 1000) if start else 0
ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() if request.headers.get("X-Forwarded-For") else (request.remote_addr or "unknown")
suspicious_matches = getattr(g, "suspicious_matches", [])
suspicious = len(suspicious_matches) > 0
request_payload_preview = getattr(g, "request_payload_preview", "")
request_json_payload = getattr(g, "request_json_payload", None)
request_form_payload = getattr(g, "request_form_payload", {})
request_headers_snapshot = getattr(g, "request_headers_snapshot", {})
auth_user_id = None
auth_wallet_address = None
auth_email = None
try:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
decoded = pyjwt.decode(
token,
options={"verify_signature": False},
algorithms=["HS256", "RS256"],
)
auth_user_id = decoded.get("user_id") or decoded.get("sub") or decoded.get("uid")
auth_wallet_address = decoded.get("wallet_address")
auth_email = decoded.get("email")
except Exception:
auth_user_id = None
response_body_preview = ""
try:
response_body_preview = response.get_data(as_text=True)[:4000]
except Exception:
response_body_preview = ""
response_content_type = response.headers.get("Content-Type", "")
parsed_response_json = None
if response_body_preview and "json" in response_content_type.lower():
try:
parsed_response_json = json.loads(response_body_preview)
except Exception:
parsed_response_json = None
system_usage = {}
if psutil is not None:
try:
vm = psutil.virtual_memory()
system_usage = {
"cpu_percent": psutil.cpu_percent(interval=None),
"memory_percent": vm.percent,
"memory_used_mb": round(vm.used / (1024 * 1024), 2),
"memory_available_mb": round(vm.available / (1024 * 1024), 2),
}
except Exception:
system_usage = {}
event_type = _infer_event_type(request.path, request.method, response.status_code, suspicious=suspicious)
action = f"{request.method} {request.path}"
log_doc = {
"timestamp": datetime.utcnow(),
"event_type": event_type,
"action": action,
"status_code": int(response.status_code),
"severity": "warning" if suspicious or response.status_code >= 400 else "info",
"path": request.path,
"method": request.method,
"query": dict(request.args),
"ip": ip,
"user_agent": request.headers.get("User-Agent", ""),
"origin": request.headers.get("Origin", ""),
"duration_ms": duration_ms,
"metadata": {
"suspicious_matches": suspicious_matches,
"content_type": request.headers.get("Content-Type", ""),
"request_body": request_payload_preview,
"response_body": response_body_preview,
"request_details": {
"query": dict(request.args),
"json": request_json_payload,
"form": request_form_payload,
"headers": request_headers_snapshot,
"content_length": request.content_length,
},
"response_details": {
"content_type": response_content_type,
"content_length": response.calculate_content_length(),
"json": parsed_response_json,
},
"usage": system_usage,
"duration_ms": duration_ms,
"auth_user_id": auth_user_id,
"auth_wallet_address": auth_wallet_address,
"auth_email": auth_email,
}
}
db.security_logs.insert_one(log_doc)
except Exception as e:
logger.debug(f"Audit log write skipped: {e}")
return response
# Enhanced logging with your configuration
logging.basicConfig(
level=logging.INFO,
+1 -1
View File
@@ -1 +1 @@
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220086786c2e82d490ba62f8e12df9157f5736c053e7cdd84543b9d7051b13134e364736f6c634300081e0033","sourceMap":"194:9169:10:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220086786c2e82d490ba62f8e12df9157f5736c053e7cdd84543b9d7051b13134e364736f6c634300081e0033","sourceMap":"194:9169:10:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Collection of functions related to the address type\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Address.sol\":\"Address\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Address.sol\":{\"keccak256\":\"0x006dd67219697fe68d7fbfdea512e7c4cb64a43565ed86171d67e844982da6fa\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://2455248c8ddd9cc6a7af76a13973cddf222072427e7b0e2a7d1aff345145e931\",\"dweb:/ipfs/QmfYjnjRbWqYpuxurqveE6HtzsY1Xx323J428AKQgtBJZm\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Address.sol":"Address"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Address.sol":{"keccak256":"0x006dd67219697fe68d7fbfdea512e7c4cb64a43565ed86171d67e844982da6fa","urls":["bzz-raw://2455248c8ddd9cc6a7af76a13973cddf222072427e7b0e2a7d1aff345145e931","dweb:/ipfs/QmfYjnjRbWqYpuxurqveE6HtzsY1Xx323J428AKQgtBJZm"],"license":"MIT"}},"version":1},"id":10}
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220f45d6402490a29b9824fef150a4c5d3dced1f2cbcfad209feaeafb4ab56e45d664736f6c63430008210033","sourceMap":"194:9169:10:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220f45d6402490a29b9824fef150a4c5d3dced1f2cbcfad209feaeafb4ab56e45d664736f6c63430008210033","sourceMap":"194:9169:10:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Collection of functions related to the address type\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Address.sol\":\"Address\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Address.sol\":{\"keccak256\":\"0x006dd67219697fe68d7fbfdea512e7c4cb64a43565ed86171d67e844982da6fa\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://2455248c8ddd9cc6a7af76a13973cddf222072427e7b0e2a7d1aff345145e931\",\"dweb:/ipfs/QmfYjnjRbWqYpuxurqveE6HtzsY1Xx323J428AKQgtBJZm\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Address.sol":"Address"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Address.sol":{"keccak256":"0x006dd67219697fe68d7fbfdea512e7c4cb64a43565ed86171d67e844982da6fa","urls":["bzz-raw://2455248c8ddd9cc6a7af76a13973cddf222072427e7b0e2a7d1aff345145e931","dweb:/ipfs/QmfYjnjRbWqYpuxurqveE6HtzsY1Xx323J428AKQgtBJZm"],"license":"MIT"}},"version":1},"id":10}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"abi":[],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Provides information about the current execution context, including the sender of the transaction and its data. While these are generally available via msg.sender and msg.data, they should not be accessed in such a direct manner, since when dealing with meta-transactions the account sending and paying for execution may not be the actual sender (as far as an application is concerned). This contract is only required for intermediate, library-like contracts.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Context.sol\":\"Context\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Context.sol\":{\"keccak256\":\"0xe2e337e6dde9ef6b680e07338c493ebea1b5fd09b43424112868e9cc1706bca7\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://6df0ddf21ce9f58271bdfaa85cde98b200ef242a05a3f85c2bc10a8294800a92\",\"dweb:/ipfs/QmRK2Y5Yc6BK7tGKkgsgn3aJEQGi5aakeSPZvS65PV8Xp3\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Context.sol":"Context"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Context.sol":{"keccak256":"0xe2e337e6dde9ef6b680e07338c493ebea1b5fd09b43424112868e9cc1706bca7","urls":["bzz-raw://6df0ddf21ce9f58271bdfaa85cde98b200ef242a05a3f85c2bc10a8294800a92","dweb:/ipfs/QmRK2Y5Yc6BK7tGKkgsgn3aJEQGi5aakeSPZvS65PV8Xp3"],"license":"MIT"}},"version":1},"id":11}
{"abi":[],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Provides information about the current execution context, including the sender of the transaction and its data. While these are generally available via msg.sender and msg.data, they should not be accessed in such a direct manner, since when dealing with meta-transactions the account sending and paying for execution may not be the actual sender (as far as an application is concerned). This contract is only required for intermediate, library-like contracts.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Context.sol\":\"Context\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Context.sol\":{\"keccak256\":\"0xe2e337e6dde9ef6b680e07338c493ebea1b5fd09b43424112868e9cc1706bca7\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://6df0ddf21ce9f58271bdfaa85cde98b200ef242a05a3f85c2bc10a8294800a92\",\"dweb:/ipfs/QmRK2Y5Yc6BK7tGKkgsgn3aJEQGi5aakeSPZvS65PV8Xp3\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Context.sol":"Context"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Context.sol":{"keccak256":"0xe2e337e6dde9ef6b680e07338c493ebea1b5fd09b43424112868e9cc1706bca7","urls":["bzz-raw://6df0ddf21ce9f58271bdfaa85cde98b200ef242a05a3f85c2bc10a8294800a92","dweb:/ipfs/QmRK2Y5Yc6BK7tGKkgsgn3aJEQGi5aakeSPZvS65PV8Xp3"],"license":"MIT"}},"version":1},"id":11}
+1 -1
View File
@@ -1 +1 @@
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220a3a42adcb4b001c32aa78ed40d4670ef16c1fc225305d63a33f8b9b9fd68df6d64736f6c634300081e0033","sourceMap":"424:971:12:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220a3a42adcb4b001c32aa78ed40d4670ef16c1fc225305d63a33f8b9b9fd68df6d64736f6c634300081e0033","sourceMap":"424:971:12:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"author\":\"Matt Condon (@shrugs)\",\"details\":\"Provides counters that can only be incremented, decremented or reset. This can be used e.g. to track the number of elements in a mapping, issuing ERC721 ids, or counting request ids. Include with `using Counters for Counters.Counter;`\",\"kind\":\"dev\",\"methods\":{},\"title\":\"Counters\",\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Counters.sol\":\"Counters\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Counters.sol\":{\"keccak256\":\"0xf0018c2440fbe238dd3a8732fa8e17a0f9dce84d31451dc8a32f6d62b349c9f1\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://59e1c62884d55b70f3ae5432b44bb3166ad71ae3acd19c57ab6ddc3c87c325ee\",\"dweb:/ipfs/QmezuXg5GK5oeA4F91EZhozBFekhq5TD966bHPH18cCqhu\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Counters.sol":"Counters"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Counters.sol":{"keccak256":"0xf0018c2440fbe238dd3a8732fa8e17a0f9dce84d31451dc8a32f6d62b349c9f1","urls":["bzz-raw://59e1c62884d55b70f3ae5432b44bb3166ad71ae3acd19c57ab6ddc3c87c325ee","dweb:/ipfs/QmezuXg5GK5oeA4F91EZhozBFekhq5TD966bHPH18cCqhu"],"license":"MIT"}},"version":1},"id":12}
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220478c3cfc4853642c2331cfe7c02aa6a34856bcaf4e69011b07e6f8fd812c59a064736f6c63430008210033","sourceMap":"424:971:12:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220478c3cfc4853642c2331cfe7c02aa6a34856bcaf4e69011b07e6f8fd812c59a064736f6c63430008210033","sourceMap":"424:971:12:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"author\":\"Matt Condon (@shrugs)\",\"details\":\"Provides counters that can only be incremented, decremented or reset. This can be used e.g. to track the number of elements in a mapping, issuing ERC721 ids, or counting request ids. Include with `using Counters for Counters.Counter;`\",\"kind\":\"dev\",\"methods\":{},\"title\":\"Counters\",\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Counters.sol\":\"Counters\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Counters.sol\":{\"keccak256\":\"0xf0018c2440fbe238dd3a8732fa8e17a0f9dce84d31451dc8a32f6d62b349c9f1\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://59e1c62884d55b70f3ae5432b44bb3166ad71ae3acd19c57ab6ddc3c87c325ee\",\"dweb:/ipfs/QmezuXg5GK5oeA4F91EZhozBFekhq5TD966bHPH18cCqhu\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Counters.sol":"Counters"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Counters.sol":{"keccak256":"0xf0018c2440fbe238dd3a8732fa8e17a0f9dce84d31451dc8a32f6d62b349c9f1","urls":["bzz-raw://59e1c62884d55b70f3ae5432b44bb3166ad71ae3acd19c57ab6ddc3c87c325ee","dweb:/ipfs/QmezuXg5GK5oeA4F91EZhozBFekhq5TD966bHPH18cCqhu"],"license":"MIT"}},"version":1},"id":12}
+1 -1
View File
@@ -1 +1 @@
{"abi":[{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"}],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{"supportsInterface(bytes4)":"01ffc9a7"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"bytes4\",\"name\":\"interfaceId\",\"type\":\"bytes4\"}],\"name\":\"supportsInterface\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}],\"devdoc\":{\"details\":\"Implementation of the {IERC165} interface. Contracts that want to implement ERC165 should inherit from this contract and override {supportsInterface} to check for the additional interface id that will be supported. For example: ```solidity function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); } ``` Alternatively, {ERC165Storage} provides an easier to use but more expensive implementation.\",\"kind\":\"dev\",\"methods\":{\"supportsInterface(bytes4)\":{\"details\":\"See {IERC165-supportsInterface}.\"}},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol\":\"ERC165\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol\":{\"keccak256\":\"0xd10975de010d89fd1c78dc5e8a9a7e7f496198085c151648f20cba166b32582b\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://fb0048dee081f6fffa5f74afc3fb328483c2a30504e94a0ddd2a5114d731ec4d\",\"dweb:/ipfs/QmZptt1nmYoA5SgjwnSgWqgUSDgm4q52Yos3xhnMv3MV43\"]},\"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol\":{\"keccak256\":\"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f\",\"dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"stateMutability":"view","type":"function","name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{"supportsInterface(bytes4)":{"details":"See {IERC165-supportsInterface}."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol":"ERC165"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol":{"keccak256":"0xd10975de010d89fd1c78dc5e8a9a7e7f496198085c151648f20cba166b32582b","urls":["bzz-raw://fb0048dee081f6fffa5f74afc3fb328483c2a30504e94a0ddd2a5114d731ec4d","dweb:/ipfs/QmZptt1nmYoA5SgjwnSgWqgUSDgm4q52Yos3xhnMv3MV43"],"license":"MIT"},"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol":{"keccak256":"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1","urls":["bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f","dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy"],"license":"MIT"}},"version":1},"id":14}
{"abi":[{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"}],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{"supportsInterface(bytes4)":"01ffc9a7"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"bytes4\",\"name\":\"interfaceId\",\"type\":\"bytes4\"}],\"name\":\"supportsInterface\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}],\"devdoc\":{\"details\":\"Implementation of the {IERC165} interface. Contracts that want to implement ERC165 should inherit from this contract and override {supportsInterface} to check for the additional interface id that will be supported. For example: ```solidity function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); } ``` Alternatively, {ERC165Storage} provides an easier to use but more expensive implementation.\",\"kind\":\"dev\",\"methods\":{\"supportsInterface(bytes4)\":{\"details\":\"See {IERC165-supportsInterface}.\"}},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol\":\"ERC165\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol\":{\"keccak256\":\"0xd10975de010d89fd1c78dc5e8a9a7e7f496198085c151648f20cba166b32582b\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://fb0048dee081f6fffa5f74afc3fb328483c2a30504e94a0ddd2a5114d731ec4d\",\"dweb:/ipfs/QmZptt1nmYoA5SgjwnSgWqgUSDgm4q52Yos3xhnMv3MV43\"]},\"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol\":{\"keccak256\":\"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f\",\"dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"stateMutability":"view","type":"function","name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{"supportsInterface(bytes4)":{"details":"See {IERC165-supportsInterface}."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol":"ERC165"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol":{"keccak256":"0xd10975de010d89fd1c78dc5e8a9a7e7f496198085c151648f20cba166b32582b","urls":["bzz-raw://fb0048dee081f6fffa5f74afc3fb328483c2a30504e94a0ddd2a5114d731ec4d","dweb:/ipfs/QmZptt1nmYoA5SgjwnSgWqgUSDgm4q52Yos3xhnMv3MV43"],"license":"MIT"},"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol":{"keccak256":"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1","urls":["bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f","dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy"],"license":"MIT"}},"version":1},"id":14}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"abi":[{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"}],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{"supportsInterface(bytes4)":"01ffc9a7"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"bytes4\",\"name\":\"interfaceId\",\"type\":\"bytes4\"}],\"name\":\"supportsInterface\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}],\"devdoc\":{\"details\":\"Interface of the ERC165 standard, as defined in the https://eips.ethereum.org/EIPS/eip-165[EIP]. Implementers can declare support of contract interfaces, which can then be queried by others ({ERC165Checker}). For an implementation, see {ERC165}.\",\"kind\":\"dev\",\"methods\":{\"supportsInterface(bytes4)\":{\"details\":\"Returns true if this contract implements the interface defined by `interfaceId`. See the corresponding https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] to learn more about how these ids are created. This function call must use less than 30 000 gas.\"}},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol\":\"IERC165\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol\":{\"keccak256\":\"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f\",\"dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"stateMutability":"view","type":"function","name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{"supportsInterface(bytes4)":{"details":"Returns true if this contract implements the interface defined by `interfaceId`. See the corresponding https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] to learn more about how these ids are created. This function call must use less than 30 000 gas."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol":"IERC165"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol":{"keccak256":"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1","urls":["bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f","dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy"],"license":"MIT"}},"version":1},"id":15}
{"abi":[{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"}],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{"supportsInterface(bytes4)":"01ffc9a7"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"bytes4\",\"name\":\"interfaceId\",\"type\":\"bytes4\"}],\"name\":\"supportsInterface\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}],\"devdoc\":{\"details\":\"Interface of the ERC165 standard, as defined in the https://eips.ethereum.org/EIPS/eip-165[EIP]. Implementers can declare support of contract interfaces, which can then be queried by others ({ERC165Checker}). For an implementation, see {ERC165}.\",\"kind\":\"dev\",\"methods\":{\"supportsInterface(bytes4)\":{\"details\":\"Returns true if this contract implements the interface defined by `interfaceId`. See the corresponding https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] to learn more about how these ids are created. This function call must use less than 30 000 gas.\"}},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol\":\"IERC165\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol\":{\"keccak256\":\"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f\",\"dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"stateMutability":"view","type":"function","name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{"supportsInterface(bytes4)":{"details":"Returns true if this contract implements the interface defined by `interfaceId`. See the corresponding https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] to learn more about how these ids are created. This function call must use less than 30 000 gas."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol":"IERC165"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol":{"keccak256":"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1","urls":["bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f","dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy"],"license":"MIT"}},"version":1},"id":15}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"abi":[{"type":"function","name":"onERC721Received","inputs":[{"name":"operator","type":"address","internalType":"address"},{"name":"from","type":"address","internalType":"address"},{"name":"tokenId","type":"uint256","internalType":"uint256"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes4","internalType":"bytes4"}],"stateMutability":"nonpayable"}],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{"onERC721Received(address,address,uint256,bytes)":"150b7a02"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"tokenId\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"data\",\"type\":\"bytes\"}],\"name\":\"onERC721Received\",\"outputs\":[{\"internalType\":\"bytes4\",\"name\":\"\",\"type\":\"bytes4\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}],\"devdoc\":{\"details\":\"Interface for any contract that wants to support safeTransfers from ERC721 asset contracts.\",\"kind\":\"dev\",\"methods\":{\"onERC721Received(address,address,uint256,bytes)\":{\"details\":\"Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} by `operator` from `from`, this function is called. It must return its Solidity selector to confirm the token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`.\"}},\"title\":\"ERC721 token receiver interface\",\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol\":\"IERC721Receiver\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol\":{\"keccak256\":\"0xa82b58eca1ee256be466e536706850163d2ec7821945abd6b4778cfb3bee37da\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://6e75cf83beb757b8855791088546b8337e9d4684e169400c20d44a515353b708\",\"dweb:/ipfs/QmYvPafLfoquiDMEj7CKHtvbgHu7TJNPSVPSCjrtjV8HjV\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"address","name":"from","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"},{"internalType":"bytes","name":"data","type":"bytes"}],"stateMutability":"nonpayable","type":"function","name":"onERC721Received","outputs":[{"internalType":"bytes4","name":"","type":"bytes4"}]}],"devdoc":{"kind":"dev","methods":{"onERC721Received(address,address,uint256,bytes)":{"details":"Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} by `operator` from `from`, this function is called. It must return its Solidity selector to confirm the token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol":"IERC721Receiver"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol":{"keccak256":"0xa82b58eca1ee256be466e536706850163d2ec7821945abd6b4778cfb3bee37da","urls":["bzz-raw://6e75cf83beb757b8855791088546b8337e9d4684e169400c20d44a515353b708","dweb:/ipfs/QmYvPafLfoquiDMEj7CKHtvbgHu7TJNPSVPSCjrtjV8HjV"],"license":"MIT"}},"version":1},"id":7}
{"abi":[{"type":"function","name":"onERC721Received","inputs":[{"name":"operator","type":"address","internalType":"address"},{"name":"from","type":"address","internalType":"address"},{"name":"tokenId","type":"uint256","internalType":"uint256"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes4","internalType":"bytes4"}],"stateMutability":"nonpayable"}],"bytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"deployedBytecode":{"object":"0x","sourceMap":"","linkReferences":{}},"methodIdentifiers":{"onERC721Received(address,address,uint256,bytes)":"150b7a02"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"tokenId\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"data\",\"type\":\"bytes\"}],\"name\":\"onERC721Received\",\"outputs\":[{\"internalType\":\"bytes4\",\"name\":\"\",\"type\":\"bytes4\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}],\"devdoc\":{\"details\":\"Interface for any contract that wants to support safeTransfers from ERC721 asset contracts.\",\"kind\":\"dev\",\"methods\":{\"onERC721Received(address,address,uint256,bytes)\":{\"details\":\"Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} by `operator` from `from`, this function is called. It must return its Solidity selector to confirm the token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`.\"}},\"title\":\"ERC721 token receiver interface\",\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol\":\"IERC721Receiver\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol\":{\"keccak256\":\"0xa82b58eca1ee256be466e536706850163d2ec7821945abd6b4778cfb3bee37da\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://6e75cf83beb757b8855791088546b8337e9d4684e169400c20d44a515353b708\",\"dweb:/ipfs/QmYvPafLfoquiDMEj7CKHtvbgHu7TJNPSVPSCjrtjV8HjV\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"address","name":"from","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"},{"internalType":"bytes","name":"data","type":"bytes"}],"stateMutability":"nonpayable","type":"function","name":"onERC721Received","outputs":[{"internalType":"bytes4","name":"","type":"bytes4"}]}],"devdoc":{"kind":"dev","methods":{"onERC721Received(address,address,uint256,bytes)":{"details":"Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} by `operator` from `from`, this function is called. It must return its Solidity selector to confirm the token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol":"IERC721Receiver"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol":{"keccak256":"0xa82b58eca1ee256be466e536706850163d2ec7821945abd6b4778cfb3bee37da","urls":["bzz-raw://6e75cf83beb757b8855791088546b8337e9d4684e169400c20d44a515353b708","dweb:/ipfs/QmYvPafLfoquiDMEj7CKHtvbgHu7TJNPSVPSCjrtjV8HjV"],"license":"MIT"}},"version":1},"id":7}
+1 -1
View File
@@ -1 +1 @@
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212207452f0ae8e85d061bad63a9a107ad312b49978d0830aa86d3756c17f6dbcc29c64736f6c634300081e0033","sourceMap":"202:12582:16:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212207452f0ae8e85d061bad63a9a107ad312b49978d0830aa86d3756c17f6dbcc29c64736f6c634300081e0033","sourceMap":"202:12582:16:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Standard math utilities missing in the Solidity language.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/math/Math.sol\":\"Math\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/math/Math.sol\":{\"keccak256\":\"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c\",\"dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/math/Math.sol":"Math"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/math/Math.sol":{"keccak256":"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3","urls":["bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c","dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS"],"license":"MIT"}},"version":1},"id":16}
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220213fb406c5c91a50fee019335c22a97ae8d977051df6576218009afaceea682e64736f6c63430008210033","sourceMap":"202:12582:16:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220213fb406c5c91a50fee019335c22a97ae8d977051df6576218009afaceea682e64736f6c63430008210033","sourceMap":"202:12582:16:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Standard math utilities missing in the Solidity language.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/math/Math.sol\":\"Math\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/math/Math.sol\":{\"keccak256\":\"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c\",\"dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/math/Math.sol":"Math"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/math/Math.sol":{"keccak256":"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3","urls":["bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c","dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS"],"license":"MIT"}},"version":1},"id":16}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220fc3cbe91605fb574b20643e793dece5fabf482e1dfa74fe76bb18c24f02a675e64736f6c634300081e0033","sourceMap":"215:1047:17:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220fc3cbe91605fb574b20643e793dece5fabf482e1dfa74fe76bb18c24f02a675e64736f6c634300081e0033","sourceMap":"215:1047:17:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Standard signed math utilities missing in the Solidity language.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol\":\"SignedMath\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol\":{\"keccak256\":\"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7\",\"dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol":"SignedMath"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol":{"keccak256":"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc","urls":["bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7","dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6"],"license":"MIT"}},"version":1},"id":17}
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220976bca310edbc44efb774b7d3260840e2af079bd8d6daf41434a8844ed9cce3a64736f6c63430008210033","sourceMap":"215:1047:17:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220976bca310edbc44efb774b7d3260840e2af079bd8d6daf41434a8844ed9cce3a64736f6c63430008210033","sourceMap":"215:1047:17:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"Standard signed math utilities missing in the Solidity language.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol\":\"SignedMath\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol\":{\"keccak256\":\"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7\",\"dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol":"SignedMath"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol":{"keccak256":"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc","urls":["bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7","dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6"],"license":"MIT"}},"version":1},"id":17}
+1 -1
View File
@@ -1 +1 @@
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212201f54765c08e65c3ecf184681909ecdb2961e29431cd7450e467f2e6f1d0606d564736f6c634300081e0033","sourceMap":"220:2559:13:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212201f54765c08e65c3ecf184681909ecdb2961e29431cd7450e467f2e6f1d0606d564736f6c634300081e0033","sourceMap":"220:2559:13:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.30+commit.73712a01\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"String operations.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Strings.sol\":\"Strings\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Strings.sol\":{\"keccak256\":\"0x3088eb2868e8d13d89d16670b5f8612c4ab9ff8956272837d8e90106c59c14a0\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://b81d9ff6559ea5c47fc573e17ece6d9ba5d6839e213e6ebc3b4c5c8fe4199d7f\",\"dweb:/ipfs/QmPCW1bFisUzJkyjroY3yipwfism9RRCigCcK1hbXtVM8n\"]},\"lib/openzeppelin-contracts/contracts/utils/math/Math.sol\":{\"keccak256\":\"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c\",\"dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS\"]},\"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol\":{\"keccak256\":\"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7\",\"dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.30+commit.73712a01"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/openzeppelin-contracts/lib/forge-std/src/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Strings.sol":"Strings"},"evmVersion":"cancun","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Strings.sol":{"keccak256":"0x3088eb2868e8d13d89d16670b5f8612c4ab9ff8956272837d8e90106c59c14a0","urls":["bzz-raw://b81d9ff6559ea5c47fc573e17ece6d9ba5d6839e213e6ebc3b4c5c8fe4199d7f","dweb:/ipfs/QmPCW1bFisUzJkyjroY3yipwfism9RRCigCcK1hbXtVM8n"],"license":"MIT"},"lib/openzeppelin-contracts/contracts/utils/math/Math.sol":{"keccak256":"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3","urls":["bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c","dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS"],"license":"MIT"},"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol":{"keccak256":"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc","urls":["bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7","dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6"],"license":"MIT"}},"version":1},"id":13}
{"abi":[],"bytecode":{"object":"0x6055604b600b8282823980515f1a607314603f577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212202e07962ed64f0cd88180b20fd760b5a33982ce5cb580902c5da0e2d012315f9264736f6c63430008210033","sourceMap":"220:2559:13:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212202e07962ed64f0cd88180b20fd760b5a33982ce5cb580902c5da0e2d012315f9264736f6c63430008210033","sourceMap":"220:2559:13:-:0;;;;;;;;","linkReferences":{}},"methodIdentifiers":{},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.33+commit.64118f21\"},\"language\":\"Solidity\",\"output\":{\"abi\":[],\"devdoc\":{\"details\":\"String operations.\",\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/openzeppelin-contracts/contracts/utils/Strings.sol\":\"Strings\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":@openzeppelin/=lib/openzeppelin-contracts/\",\":openzeppelin-contracts/=lib/openzeppelin-contracts/\",\":openzeppelin/=lib/openzeppelin-contracts/contracts/\"]},\"sources\":{\"lib/openzeppelin-contracts/contracts/utils/Strings.sol\":{\"keccak256\":\"0x3088eb2868e8d13d89d16670b5f8612c4ab9ff8956272837d8e90106c59c14a0\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://b81d9ff6559ea5c47fc573e17ece6d9ba5d6839e213e6ebc3b4c5c8fe4199d7f\",\"dweb:/ipfs/QmPCW1bFisUzJkyjroY3yipwfism9RRCigCcK1hbXtVM8n\"]},\"lib/openzeppelin-contracts/contracts/utils/math/Math.sol\":{\"keccak256\":\"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c\",\"dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS\"]},\"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol\":{\"keccak256\":\"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7\",\"dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.33+commit.64118f21"},"language":"Solidity","output":{"abi":[],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/=lib/openzeppelin-contracts/","openzeppelin-contracts/=lib/openzeppelin-contracts/","openzeppelin/=lib/openzeppelin-contracts/contracts/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/openzeppelin-contracts/contracts/utils/Strings.sol":"Strings"},"evmVersion":"prague","libraries":{}},"sources":{"lib/openzeppelin-contracts/contracts/utils/Strings.sol":{"keccak256":"0x3088eb2868e8d13d89d16670b5f8612c4ab9ff8956272837d8e90106c59c14a0","urls":["bzz-raw://b81d9ff6559ea5c47fc573e17ece6d9ba5d6839e213e6ebc3b4c5c8fe4199d7f","dweb:/ipfs/QmPCW1bFisUzJkyjroY3yipwfism9RRCigCcK1hbXtVM8n"],"license":"MIT"},"lib/openzeppelin-contracts/contracts/utils/math/Math.sol":{"keccak256":"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3","urls":["bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c","dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS"],"license":"MIT"},"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol":{"keccak256":"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc","urls":["bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7","dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6"],"license":"MIT"}},"version":1},"id":13}
@@ -1 +1 @@
{"id":"1d1b0b35c357d500","source_id_to_path":{"0":"contracts/CertificateNFT.sol","1":"lib/openzeppelin-contracts/contracts/access/Ownable.sol","2":"lib/openzeppelin-contracts/contracts/interfaces/IERC165.sol","3":"lib/openzeppelin-contracts/contracts/interfaces/IERC4906.sol","4":"lib/openzeppelin-contracts/contracts/interfaces/IERC721.sol","5":"lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol","6":"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol","7":"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol","8":"lib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721URIStorage.sol","9":"lib/openzeppelin-contracts/contracts/token/ERC721/extensions/IERC721Metadata.sol","10":"lib/openzeppelin-contracts/contracts/utils/Address.sol","11":"lib/openzeppelin-contracts/contracts/utils/Context.sol","12":"lib/openzeppelin-contracts/contracts/utils/Counters.sol","13":"lib/openzeppelin-contracts/contracts/utils/Strings.sol","14":"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol","15":"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol","16":"lib/openzeppelin-contracts/contracts/utils/math/Math.sol","17":"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol"},"language":"Solidity"}
{"id":"b361ab5df899e494","source_id_to_path":{"0":"contracts/CertificateNFT.sol","1":"lib/openzeppelin-contracts/contracts/access/Ownable.sol","2":"lib/openzeppelin-contracts/contracts/interfaces/IERC165.sol","3":"lib/openzeppelin-contracts/contracts/interfaces/IERC4906.sol","4":"lib/openzeppelin-contracts/contracts/interfaces/IERC721.sol","5":"lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol","6":"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol","7":"lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol","8":"lib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721URIStorage.sol","9":"lib/openzeppelin-contracts/contracts/token/ERC721/extensions/IERC721Metadata.sol","10":"lib/openzeppelin-contracts/contracts/utils/Address.sol","11":"lib/openzeppelin-contracts/contracts/utils/Context.sol","12":"lib/openzeppelin-contracts/contracts/utils/Counters.sol","13":"lib/openzeppelin-contracts/contracts/utils/Strings.sol","14":"lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol","15":"lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol","16":"lib/openzeppelin-contracts/contracts/utils/math/Math.sol","17":"lib/openzeppelin-contracts/contracts/utils/math/SignedMath.sol"},"language":"Solidity"}
+941 -2
View File
File diff suppressed because it is too large Load Diff
+618
View File
@@ -7,6 +7,7 @@ import jwt
import logging
from eth_account.messages import encode_defunct
from web3 import Web3
from activity_logger import log_user_activity
bp = Blueprint('auth', __name__)
logger = logging.getLogger(__name__)
@@ -131,6 +132,8 @@ def verify_signature():
# Create new user
user = {
"wallet_address": wallet_address.lower(),
"role": "student",
"status": "active",
"created_at": datetime.now(),
"last_login": datetime.now(),
"login_count": 1
@@ -138,7 +141,31 @@ def verify_signature():
result = db.users.insert_one(user)
user["_id"] = str(result.inserted_id)
logger.info(f"✅ Created new user: {wallet_address}")
log_user_activity(
db,
wallet_address.lower(),
"auth_register",
"Account registered",
"Created account via wallet authentication",
{"auth_method": "wallet"},
)
else:
account_status = str(user.get("status", "active")).lower().strip()
if account_status == "banned":
logger.warning(f"⛔ Banned wallet login blocked: {wallet_address}")
log_user_activity(
db,
wallet_address.lower(),
"account_status",
"Login blocked",
"Login blocked because account is banned",
{"status": "banned"},
)
return jsonify({
"success": False,
"error": "Your account is banned. Contact admin."
}), 403
# Update existing user
db.users.update_one(
{"wallet_address": wallet_address.lower()},
@@ -150,6 +177,15 @@ def verify_signature():
user["_id"] = str(user["_id"])
logger.info(f"✅ Updated existing user: {wallet_address}")
log_user_activity(
db,
wallet_address.lower(),
"auth_login",
"Login successful",
"Wallet login completed successfully",
{"auth_method": "wallet"},
)
# Generate JWT token
token_payload = {
"user_id": user["wallet_address"],
@@ -164,6 +200,12 @@ def verify_signature():
user_response = {
"id": user["wallet_address"],
"wallet_address": user["wallet_address"],
"email": user.get("email", ""),
"name": user.get("name", ""),
"bio": user.get("bio", ""),
"avatar": user.get("avatar", ""),
"role": user.get("role", "student"),
"status": user.get("status", "active"),
"created_at": user["created_at"].isoformat() if isinstance(user["created_at"], datetime) else str(user["created_at"]),
"last_login": user["last_login"].isoformat() if isinstance(user["last_login"], datetime) else str(user["last_login"])
}
@@ -183,3 +225,579 @@ def verify_signature():
"success": False,
"error": str(e)
}), 500
@bp.route('/register', methods=['POST', 'OPTIONS'])
def register():
"""Register a new user with email and password"""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
try:
data = request.get_json()
email = data.get('email', '').strip().lower()
password = data.get('password', '')
username = data.get('username', '').strip()
if not email or not password:
return jsonify({
"success": False,
"error": "Email and password are required"
}), 400
if len(password) < 6:
return jsonify({
"success": False,
"error": "Password must be at least 6 characters"
}), 400
# Check if user already exists
existing_user = db.users.find_one({"email": email})
if existing_user:
return jsonify({
"success": False,
"error": "Email already registered"
}), 409
# Hash password using simple approach for development
# TODO: Use werkzeug.security.generate_password_hash for production
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest()
# Create new user
user = {
"email": email,
"username": username or email.split("@")[0],
"password_hash": password_hash,
"name": "",
"bio": "",
"avatar": "",
"role": "student",
"status": "active",
"created_at": datetime.now(),
"last_login": datetime.now(),
"login_count": 1,
"auth_method": "email"
}
result = db.users.insert_one(user)
user["_id"] = str(result.inserted_id)
# Generate JWT token
token_payload = {
"user_id": str(result.inserted_id),
"email": email,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(days=7)
}
token = jwt.encode(token_payload, JWT_SECRET, algorithm="HS256")
user_response = {
"id": str(result.inserted_id),
"email": email,
"username": username or email.split("@")[0],
"name": "",
"bio": "",
"avatar": "",
"role": "student",
"status": "active",
"created_at": user["created_at"].isoformat(),
"last_login": user["last_login"].isoformat()
}
log_user_activity(
db,
str(result.inserted_id),
"auth_register",
"Account registered",
"Created account with email and password",
{"auth_method": "email"},
)
logger.info(f"✅ New user registered: {email}")
return jsonify({
"success": True,
"token": token,
"user": user_response,
"message": "Registration successful"
}), 201
except Exception as e:
logger.error(f"❌ Error during registration: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@bp.route('/login', methods=['POST', 'OPTIONS'])
def login():
"""Login with email and password"""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
try:
data = request.get_json()
email = data.get('email', '').strip().lower()
password = data.get('password', '')
if not email or not password:
return jsonify({
"success": False,
"error": "Email and password are required"
}), 400
# Find user by email
user = db.users.find_one({"email": email})
if not user:
return jsonify({
"success": False,
"error": "Invalid email or password"
}), 401
account_status = str(user.get("status", "active")).lower().strip()
if account_status == "banned":
logger.warning(f"⛔ Banned email login blocked: {email}")
log_user_activity(
db,
str(user.get("_id")),
"account_status",
"Login blocked",
"Login blocked because account is banned",
{"status": "banned", "email": email},
)
return jsonify({
"success": False,
"error": "Your account is banned. Contact admin."
}), 403
if account_status == "suspended":
log_user_activity(
db,
str(user.get("_id")),
"account_status",
"Login attempted while suspended",
"User logged in while account status is suspended",
{"status": "suspended", "email": email},
)
# Verify password
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest()
if password_hash != user.get('password_hash'):
return jsonify({
"success": False,
"error": "Invalid email or password"
}), 401
# Update last login
db.users.update_one(
{"_id": user["_id"]},
{
"$set": {"last_login": datetime.now()},
"$inc": {"login_count": 1}
}
)
# Generate JWT token
token_payload = {
"user_id": str(user["_id"]),
"email": email,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(days=7)
}
token = jwt.encode(token_payload, JWT_SECRET, algorithm="HS256")
user_response = {
"id": str(user["_id"]),
"email": email,
"username": user.get('username', ''),
"name": user.get('name', ''),
"bio": user.get('bio', ''),
"avatar": user.get('avatar', ''),
"role": user.get('role', 'student'),
"status": user.get('status', 'active'),
"created_at": user["created_at"].isoformat() if isinstance(user["created_at"], datetime) else str(user["created_at"]),
"last_login": user["last_login"].isoformat() if isinstance(user["last_login"], datetime) else str(user["last_login"])
}
log_user_activity(
db,
str(user.get("_id")),
"auth_login",
"Login successful",
"Email login completed successfully",
{"auth_method": "email", "email": email},
)
logger.info(f"✅ User logged in: {email}")
return jsonify({
"success": True,
"token": token,
"user": user_response,
"message": "Login successful"
})
except Exception as e:
logger.error(f"❌ Error during login: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@bp.route('/profile/update', methods=['POST', 'OPTIONS'])
def update_profile():
"""Update user profile (name, bio, avatar)"""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
try:
# Get token from header
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({
"success": False,
"error": "Authorization header required"
}), 401
token = auth_header.split('Bearer ')[1]
# Verify and decode token
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
user_id = payload.get('user_id')
except jwt.InvalidTokenError:
return jsonify({
"success": False,
"error": "Invalid token"
}), 401
data = request.get_json()
name = data.get('name', '').strip()
bio = data.get('bio', '').strip()
avatar = data.get('avatar', '').strip()
# Update user profile
from bson.objectid import ObjectId
result = db.users.update_one(
{"_id": ObjectId(user_id)},
{
"$set": {
"name": name,
"bio": bio,
"avatar": avatar,
"updated_at": datetime.now()
}
}
)
if result.matched_count == 0:
return jsonify({
"success": False,
"error": "User not found"
}), 404
# Get updated user
user = db.users.find_one({"_id": ObjectId(user_id)})
user_response = {
"id": str(user["_id"]),
"email": user.get('email', ''),
"username": user.get('username', ''),
"name": user.get('name', ''),
"bio": user.get('bio', ''),
"avatar": user.get('avatar', ''),
"role": user.get('role', 'student'),
"status": user.get('status', 'active'),
"created_at": user["created_at"].isoformat() if isinstance(user["created_at"], datetime) else str(user["created_at"]),
"last_login": user["last_login"].isoformat() if isinstance(user["last_login"], datetime) else str(user["last_login"])
}
logger.info(f"✅ Profile updated for user: {user_id}")
return jsonify({
"success": True,
"user": user_response,
"message": "Profile updated successfully"
})
except Exception as e:
logger.error(f"❌ Error updating profile: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@bp.route('/metamask/add-email', methods=['POST', 'OPTIONS'])
def add_metamask_email():
"""Store contact email for MetaMask wallet"""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
try:
# Get token from header
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({
"success": False,
"error": "Authorization header required"
}), 401
token = auth_header.split('Bearer ')[1]
# Verify and decode token
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
wallet_address = payload.get('wallet_address')
if not wallet_address:
wallet_address = payload.get('user_id')
except jwt.InvalidTokenError:
return jsonify({
"success": False,
"error": "Invalid token"
}), 401
data = request.get_json()
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
if not email:
return jsonify({
"success": False,
"error": "Email is required"
}), 400
# Validate email format
import re
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
return jsonify({
"success": False,
"error": "Invalid email format"
}), 400
# Check if email already used by different wallet
existing_user = db.users.find_one({"email": email, "wallet_address": {"$ne": wallet_address.lower()}})
if existing_user:
return jsonify({
"success": False,
"error": "Email already associated with another wallet"
}), 409
# Update user with email and name
from bson.objectid import ObjectId
# Try updating by wallet address first (for new users)
result = db.users.update_one(
{"wallet_address": wallet_address.lower()},
{
"$set": {
"email": email,
"name": name or "",
"updated_at": datetime.now()
}
}
)
if result.matched_count == 0:
return jsonify({
"success": False,
"error": "User not found"
}), 404
# Get updated user
user = db.users.find_one({"wallet_address": wallet_address.lower()})
user_response = {
"id": str(user.get("_id", wallet_address)),
"wallet_address": user.get("wallet_address", wallet_address),
"email": user.get("email", ""),
"name": user.get("name", ""),
"bio": user.get("bio", ""),
"avatar": user.get("avatar", ""),
"role": user.get("role", "student"),
"status": user.get("status", "active"),
"created_at": user.get("created_at", datetime.now()).isoformat() if isinstance(user.get("created_at"), datetime) else str(user.get("created_at", datetime.now())),
"last_login": user.get("last_login", datetime.now()).isoformat() if isinstance(user.get("last_login"), datetime) else str(user.get("last_login", datetime.now()))
}
logger.info(f"✅ Email added for MetaMask wallet: {wallet_address}")
return jsonify({
"success": True,
"user": user_response,
"message": "Email saved successfully"
})
except Exception as e:
logger.error(f"❌ Error saving MetaMask email: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@bp.route('/verify-token', methods=['POST', 'OPTIONS'])
def verify_token():
"""Validate JWT token and return the latest user payload."""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
try:
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({"valid": False, "error": "Authorization header required"}), 401
token = auth_header.split('Bearer ')[1]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except jwt.InvalidTokenError:
return jsonify({"valid": False, "error": "Invalid token"}), 401
user = None
wallet_address = payload.get('wallet_address')
email = payload.get('email')
user_id = payload.get('user_id')
if wallet_address:
user = db.users.find_one({"wallet_address": str(wallet_address).lower()})
elif email:
user = db.users.find_one({"email": str(email).lower()})
elif user_id:
try:
from bson.objectid import ObjectId
user = db.users.find_one({"_id": ObjectId(user_id)})
except Exception:
user = None
if not user:
return jsonify({"valid": False, "error": "User not found"}), 404
status = str(user.get("status", "active")).lower().strip()
if status == "banned":
return jsonify({"valid": False, "error": "Account is banned"}), 403
user_response = {
"id": str(user.get("_id", user.get("wallet_address", ""))),
"wallet_address": user.get("wallet_address", ""),
"email": user.get("email", ""),
"username": user.get("username", ""),
"name": user.get("name", ""),
"bio": user.get("bio", ""),
"avatar": user.get("avatar", ""),
"role": user.get("role", "student"),
"status": user.get("status", "active"),
"created_at": user.get("created_at", datetime.now()).isoformat() if isinstance(user.get("created_at"), datetime) else str(user.get("created_at", datetime.now())),
"last_login": user.get("last_login", datetime.now()).isoformat() if isinstance(user.get("last_login"), datetime) else str(user.get("last_login", datetime.now())),
}
return jsonify({"valid": True, "user": user_response})
except Exception as e:
logger.error(f"❌ verify-token error: {str(e)}")
return jsonify({"valid": False, "error": str(e)}), 500
@bp.route('/me', methods=['GET', 'OPTIONS'])
def get_me():
"""Return authenticated user profile for current token."""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
verify_resp = verify_token()
try:
body, status = verify_resp
if status != 200:
return body, status
data = body.get_json()
return jsonify({"success": True, "user": data.get("user", {})})
except Exception:
return verify_resp
@bp.route('/upload-image', methods=['POST', 'OPTIONS'])
def upload_image():
"""Upload and convert image (PNG/JPG only) to base64"""
if request.method == "OPTIONS":
return jsonify({'status': 'ok'})
try:
# Get token from header
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({
"success": False,
"error": "Authorization header required"
}), 401
token = auth_header.split('Bearer ')[1]
# Verify and decode token
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
user_id = payload.get('user_id')
except jwt.InvalidTokenError:
return jsonify({
"success": False,
"error": "Invalid token"
}), 401
# Check if file is in request
if 'file' not in request.files:
return jsonify({
"success": False,
"error": "No file provided"
}), 400
file = request.files['file']
if file.filename == '':
return jsonify({
"success": False,
"error": "No file selected"
}), 400
# Validate file type - only PNG and JPG
allowed_extensions = {'png', 'jpg', 'jpeg'}
file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''
if file_ext not in allowed_extensions:
return jsonify({
"success": False,
"error": "Only PNG and JPG formats are allowed"
}), 400
# Validate file size (max 5MB)
file.seek(0, 2) # Seek to end
file_size = file.tell()
file.seek(0) # Seek back to start
max_size = 5 * 1024 * 1024 # 5MB
if file_size > max_size:
return jsonify({
"success": False,
"error": "File size must be less than 5MB"
}), 400
# Read file and convert to base64
import base64
file_data = file.read()
base64_image = base64.b64encode(file_data).decode('utf-8')
# Create data URL for the image
mime_type = f"image/{file_ext if file_ext != 'jpg' else 'jpeg'}"
data_url = f"data:{mime_type};base64,{base64_image}"
logger.info(f"✅ Image uploaded for user: {user_id}, size: {file_size} bytes")
return jsonify({
"success": True,
"image": data_url,
"size": file_size,
"message": "Image uploaded successfully"
}), 200
except Exception as e:
logger.error(f"❌ Error uploading image: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
}), 500
+51 -5
View File
@@ -8,9 +8,16 @@ import uuid
from datetime import datetime
import docker
import psutil
from pymongo import MongoClient
from activity_logger import log_user_activity, resolve_user_identity
bp = Blueprint('coding', __name__)
# MongoDB connection
mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
client = MongoClient(mongo_uri)
db = client.openlearnx
def secure_execution_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -35,6 +42,20 @@ def start_coding_session():
session['course_id'] = course_id
session['lesson_id'] = lesson_id
identity = resolve_user_identity(request, db)
log_user_activity(
db,
identity.get("user_id"),
"coding",
"Started coding session",
"Entered secure coding session",
{
"session_id": session_id,
"course_id": course_id,
"lesson_id": lesson_id,
},
)
return jsonify({
"success": True,
"session_id": session_id,
@@ -93,6 +114,36 @@ def submit_coding_test():
test_result
)
identity = resolve_user_identity(request, db)
resolved_user_id = identity.get("user_id")
if resolved_user_id:
db.user_submissions.insert_one({
"user_id": resolved_user_id,
"session_id": session.get('coding_session_id'),
"course_id": session.get('course_id'),
"problem_id": problem_id,
"score": test_result.get('score', 0),
"points_earned": int(test_result.get('score', 0)),
"submitted_at": datetime.now(),
"status": "submitted",
})
log_user_activity(
db,
resolved_user_id,
"coding",
"Submitted coding solution",
f"Submitted coding test for problem '{problem_id}'",
{
"submission_id": submission_id,
"problem_id": problem_id,
"score": test_result.get('score', 0),
"passed": test_result.get('passed', 0),
"total": test_result.get('total', 0),
},
points_earned=int(test_result.get('score', 0)),
)
return jsonify({
"success": True,
"submission_id": submission_id,
@@ -184,11 +235,6 @@ def get_run_command(language, filename):
def log_coding_attempt(session_id, code, language):
"""Log all coding attempts for monitoring"""
from pymongo import MongoClient
client = MongoClient(os.getenv('MONGODB_URI', 'mongodb://localhost:27017/'))
db = client.openlearnx
db.coding_logs.insert_one({
"session_id": session_id,
"code": code,
+95 -1
View File
@@ -1,6 +1,8 @@
from flask import Blueprint, jsonify, current_app
from flask import Blueprint, jsonify, current_app, request
from pymongo import MongoClient
import os
from datetime import datetime
from activity_logger import log_user_activity, resolve_user_identity
bp = Blueprint('courses', __name__)
@@ -68,6 +70,38 @@ def get_lesson(course_id, lesson_id):
def mark_lesson_complete(course_id, lesson_id):
"""Mark a lesson as completed for the user"""
try:
identity = resolve_user_identity(request, db)
user_id = identity.get("user_id")
if user_id:
course = db.courses.find_one({"id": course_id}, {"title": 1}) or {}
lesson = db.lessons.find_one({"id": lesson_id, "course_id": course_id}, {"title": 1}) or {}
db.user_courses.update_one(
{"user_id": user_id, "course_id": course_id},
{
"$set": {
"user_id": user_id,
"course_id": course_id,
"last_activity_at": datetime.utcnow(),
"completed_at": datetime.utcnow(),
"completed": True,
},
"$addToSet": {"lessons_completed": lesson_id},
},
upsert=True,
)
log_user_activity(
db,
user_id,
"course",
"Lesson completed",
f"Completed lesson '{lesson.get('title', lesson_id)}' in course '{course.get('title', course_id)}'",
{"course_id": course_id, "lesson_id": lesson_id},
points_earned=10,
)
return jsonify({
"success": True,
"message": f"Lesson {lesson_id} marked as complete",
@@ -76,6 +110,66 @@ def mark_lesson_complete(course_id, lesson_id):
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/<course_id>/activity", methods=["POST"])
def log_course_activity(course_id):
"""Log course interactions like view/start for real dashboard activity."""
try:
identity = resolve_user_identity(request, db)
user_id = identity.get("user_id")
if not user_id:
return jsonify({"success": False, "error": "Authentication required"}), 401
data = request.get_json(silent=True) or {}
action = str(data.get("action") or "view").strip().lower()
lesson_id = str(data.get("lesson_id") or "").strip()
course = db.courses.find_one({"id": course_id}, {"title": 1}) or {}
lesson_title = lesson_id
if lesson_id:
lesson = db.lessons.find_one({"id": lesson_id, "course_id": course_id}, {"title": 1}) or {}
lesson_title = lesson.get("title", lesson_id)
if action == "start":
title = "Course started"
description = f"Started course '{course.get('title', course_id)}'"
elif action == "lesson_view":
title = "Lesson viewed"
description = f"Viewed lesson '{lesson_title}' in course '{course.get('title', course_id)}'"
else:
title = "Course viewed"
description = f"Opened course '{course.get('title', course_id)}'"
log_user_activity(
db,
user_id,
"course",
title,
description,
{"course_id": course_id, "lesson_id": lesson_id, "action": action},
)
db.user_courses.update_one(
{"user_id": user_id, "course_id": course_id},
{
"$set": {
"user_id": user_id,
"course_id": course_id,
"last_activity_at": datetime.utcnow(),
},
"$setOnInsert": {
"started_at": datetime.utcnow(),
"completed": False,
"lessons_completed": [],
},
},
upsert=True,
)
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/<course_id>/progress", methods=["GET"])
def get_course_progress(course_id):
"""Get user's progress in a specific course"""
+160 -4
View File
@@ -1,5 +1,5 @@
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pymongo import MongoClient
import os
from bson import ObjectId
@@ -188,8 +188,11 @@ def get_comprehensive_stats():
"courses_completed": courses_completed,
"coding_problems_solved": coding_problems_solved,
"quiz_accuracy": quiz_accuracy,
"coding_streak": coding_streak,
"longest_streak": max(longest_streak, coding_streak),
"streak_data": {
"current_streak": coding_streak,
"best_streak": max(longest_streak, coding_streak),
"last_active_date": datetime.now().isoformat()
},
"total_courses": len(courses),
"total_quizzes": len(quizzes),
"global_rank": calculate_real_global_rank(user_stats, user_id) if user_stats else 0,
@@ -271,8 +274,142 @@ def get_recent_activity():
logger.info(f"📋 Fetching REAL activity for wallet: {user_id}")
identity_candidates = {str(user_id)}
if wallet_address:
identity_candidates.add(str(wallet_address).lower())
# Resolve user identity aliases to avoid missing activity across auth methods.
user_doc = None
try:
maybe_oid = ObjectId(str(user_id))
user_doc = db.users.find_one({"_id": maybe_oid})
except Exception:
user_doc = db.users.find_one({"wallet_address": str(user_id).lower()}) or db.users.find_one({"email": str(user_id).lower()})
if user_doc:
if user_doc.get("_id"):
identity_candidates.add(str(user_doc.get("_id")))
if user_doc.get("wallet_address"):
identity_candidates.add(str(user_doc.get("wallet_address")).lower())
if user_doc.get("email"):
identity_candidates.add(str(user_doc.get("email")).lower())
logger.info(f"📋 Recent activity identity candidates: {sorted(identity_candidates)}")
activities = []
def parse_datetime(value):
if isinstance(value, datetime):
return value
if isinstance(value, str):
candidate = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(candidate)
except Exception:
return None
return None
def to_utc_display(value):
dt = parse_datetime(value) or datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
# Primary source: explicit user activity event log.
event_docs = list(
db.user_activity_events.find({"user_id": {"$in": list(identity_candidates)}}).sort("occurred_at", -1).limit(150)
)
for item in event_docs:
occurred_at = item.get("occurred_at") or item.get("completed_at") or datetime.now(timezone.utc)
activities.append({
"id": str(item.get("_id", uuid.uuid4())),
"type": item.get("type", "activity"),
"title": item.get("title", "User Activity"),
"description": item.get("description", "Activity recorded"),
"completed_at": parse_datetime(occurred_at).isoformat() if parse_datetime(occurred_at) else datetime.now(timezone.utc).isoformat(),
"timestamp_utc": item.get("timestamp_utc") or to_utc_display(occurred_at),
"points_earned": int(item.get("points_earned", 0) or 0),
"success_rate": item.get("success_rate", 0),
"difficulty": item.get("difficulty", ""),
"blockchain_verified": item.get("blockchain_verified", False)
})
# Include admin/account status events from security logs as real activity fallback.
admin_status_logs = list(
db.security_logs.find({
"event_type": "admin_user_status",
"metadata.user_id": {"$in": list(identity_candidates)}
}).sort("timestamp", -1).limit(50)
)
for item in admin_status_logs:
occurred_at = item.get("timestamp", datetime.now(timezone.utc))
metadata = item.get("metadata") or {}
new_status = metadata.get("status") or metadata.get("new_status") or "updated"
activities.append({
"id": str(item.get("_id", uuid.uuid4())),
"type": "account_status",
"title": f"Account status changed to {new_status}",
"description": f"Admin changed your account status to {new_status}",
"completed_at": parse_datetime(occurred_at).isoformat() if parse_datetime(occurred_at) else datetime.now(timezone.utc).isoformat(),
"timestamp_utc": to_utc_display(occurred_at),
"points_earned": 0,
"success_rate": 0,
"difficulty": "",
"blockchain_verified": False,
})
# Fallback source: authenticated request audit logs for this user.
audit_logs = list(
db.security_logs.find({
"$or": [
{"metadata.auth_user_id": {"$in": list(identity_candidates)}},
{"metadata.auth_wallet_address": {"$in": list(identity_candidates)}},
{"metadata.auth_email": {"$in": list(identity_candidates)}},
]
}).sort("timestamp", -1).limit(150)
)
for item in audit_logs:
path = str(item.get("path") or "")
method = str(item.get("method") or "")
ts = item.get("timestamp", datetime.now(timezone.utc))
if not any(segment in path for segment in ["/api/quizzes", "/api/exam", "/api/coding", "/api/courses", "/api/auth"]):
continue
log_type = "activity"
title = f"{method} {path}"
description = f"API activity on {path}"
if "/api/quizzes" in path:
log_type = "quiz"
title = "Quiz activity"
description = f"{method} {path}"
elif "/api/exam" in path or "/api/coding" in path:
log_type = "coding"
title = "Coding activity"
description = f"{method} {path}"
elif "/api/courses" in path:
log_type = "course"
title = "Course activity"
description = f"{method} {path}"
elif "/api/auth" in path:
log_type = "auth_login"
title = "Authentication activity"
description = f"{method} {path}"
activities.append({
"id": str(item.get("_id", uuid.uuid4())),
"type": log_type,
"title": title,
"description": description,
"completed_at": parse_datetime(ts).isoformat() if parse_datetime(ts) else datetime.now(timezone.utc).isoformat(),
"timestamp_utc": to_utc_display(ts),
"points_earned": 0,
"success_rate": 0,
"difficulty": "",
"blockchain_verified": False,
})
# ✅ ONLY REAL ACTIVITY SOURCES
activity_sources = [
(db.user_courses, "course", "Course Activity", "completed_at"),
@@ -285,7 +422,7 @@ def get_recent_activity():
try:
# Get ONLY real MongoDB data
recent_items = list(collection.find(
{"user_id": user_id}
{"user_id": {"$in": list(identity_candidates)}}
).sort(date_field, -1).limit(20))
for item in recent_items:
@@ -305,6 +442,7 @@ def get_recent_activity():
"title": item.get('title', item.get('name', default_title)),
"description": format_real_activity_description(item, activity_type),
"completed_at": completed_at.isoformat(),
"timestamp_utc": to_utc_display(completed_at),
"points_earned": item.get('points', item.get('points_earned', 0)),
"success_rate": item.get('score', item.get('completion_percentage', 0)),
"difficulty": item.get('difficulty', ''),
@@ -314,8 +452,26 @@ def get_recent_activity():
logger.warning(f"⚠️ Failed to fetch {activity_type} activities: {e}")
continue
# Exclude known placeholder/demo activity content from older seeded data.
fake_markers = {
"completed react fundamentals",
"scored 95% on javascript quiz",
"7-day learning streak achieved",
"moved up 5 positions in leaderboard",
}
filtered_activities = []
for entry in activities:
entry_text = f"{entry.get('title', '')} {entry.get('description', '')}".strip().lower()
if any(marker in entry_text for marker in fake_markers):
continue
filtered_activities.append(entry)
activities = filtered_activities
# Sort by completion date
activities.sort(key=lambda x: x['completed_at'], reverse=True)
activities = activities[:100]
logger.info(f"✅ Found {len(activities)} REAL activities for wallet {user_id}")
return jsonify({
+16
View File
@@ -5,6 +5,7 @@ import string
from datetime import datetime, timedelta
from pymongo import MongoClient
import os
from activity_logger import log_user_activity, resolve_user_identity
bp = Blueprint('exam', __name__)
@@ -256,6 +257,21 @@ def join_exam():
print(f"✅ Participant {student_name} joined exam {exam_code}")
identity = resolve_user_identity(request, db)
log_user_activity(
db,
identity.get("user_id"),
"exam",
"Joined coding exam",
f"Joined exam '{exam.get('title', exam_code)}' as {student_name}",
{
"exam_code": exam_code,
"exam_title": exam.get("title"),
"student_name": student_name,
"session_id": participant.get("session_id"),
},
)
return jsonify({
"success": True,
"message": f"Successfully joined exam: {exam['title']}",
+224
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import uuid
import random
import string
from activity_logger import log_user_activity, resolve_user_identity
bp = Blueprint('quizzes', __name__)
@@ -233,6 +234,24 @@ def join_room():
print(f"✅ User joined room: {username} -> {room_code}")
identity = resolve_user_identity(request, db)
resolved_user_id = identity.get("user_id") or data.get("user_id") or data.get("wallet_address")
if isinstance(resolved_user_id, str):
resolved_user_id = resolved_user_id.strip().lower() if resolved_user_id.startswith("0x") else resolved_user_id.strip()
log_user_activity(
db,
resolved_user_id,
"quiz",
"Joined quiz room",
f"Joined quiz room '{room.get('title', room_code)}' as {username}",
{
"room_code": room_code,
"room_title": room.get("title"),
"username": username,
"session_id": participant_session.get("session_id"),
},
)
return jsonify({
"success": True,
"message": f"Successfully joined quiz room '{room.get('title')}'",
@@ -424,6 +443,50 @@ def submit_answer(session_id):
}}
)
identity = resolve_user_identity(request, db)
resolved_user_id = identity.get("user_id") or data.get("user_id") or data.get("wallet_address")
if isinstance(resolved_user_id, str):
resolved_user_id = resolved_user_id.strip().lower() if resolved_user_id.startswith("0x") else resolved_user_id.strip()
if resolved_user_id:
db.user_quizzes.update_one(
{
"user_id": resolved_user_id,
"session_id": session_id,
"question_id": question_data.get("question_id"),
},
{
"$set": {
"user_id": resolved_user_id,
"session_id": session_id,
"room_code": room.get("room_code"),
"room_title": room.get("title"),
"question_id": question_data.get("question_id"),
"score": participant.get("score", 0),
"completed_at": datetime.now(),
"is_correct": is_correct,
"difficulty": current_difficulty,
"username": participant.get("username"),
}
},
upsert=True,
)
log_user_activity(
db,
resolved_user_id,
"quiz",
"Answered quiz question",
f"Answered a {current_difficulty} question in '{room.get('title', 'Quiz Room')}'",
{
"session_id": session_id,
"room_code": room.get("room_code"),
"room_title": room.get("title"),
"is_correct": is_correct,
"difficulty": current_difficulty,
},
points_earned=question_data.get('points', 10) if is_correct else 0,
)
# Get AI prediction for comparison (if available)
ai_feedback = None
ai_service = get_ai_service()
@@ -479,6 +542,68 @@ def submit_answer(session_id):
# ✅ AI QUESTION GENERATION - IMPROVED VERSION
# ===================================================================
@bp.route('/generate-ai', methods=['POST', 'OPTIONS'])
def generate_ai_quiz():
"""Generate a traditional quiz directly using AI"""
if request.method == "OPTIONS":
response = jsonify({'status': 'ok'})
response.headers.add("Access-Control-Allow-Origin", "*")
response.headers.add("Access-Control-Allow-Headers", "Content-Type,Authorization")
response.headers.add("Access-Control-Allow-Methods", "POST,OPTIONS")
return response
try:
data = request.get_json()
topic = data.get('topic', 'General')
difficulty = data.get('difficulty', 'medium')
num_questions = int(data.get('num_questions', 5))
print(f"🤖 AI Quiz Generation: topic={topic}, difficulty={difficulty}, questions={num_questions}")
ai_service = get_ai_service()
if not ai_service:
return jsonify({
"success": False,
"error": "AI service not available"
}), 503
# Generate questions using AI service
generated_data = ai_service.generate_quiz(
topic=topic,
difficulty=difficulty,
num_questions=num_questions
)
# Save to database
db = get_db()
quiz_result = db.quizzes.insert_one({
"id": str(uuid.uuid4()),
"title": generated_data.get('title', f"AI Quiz - {topic}"),
"description": generated_data.get('description', ''),
"difficulty": difficulty,
"questions": generated_data.get('questions', []),
"created_at": datetime.now().isoformat(),
"total_points": len(generated_data.get('questions', [])) * 10,
"generated_by": "AI",
"topic": topic
})
# Get the saved quiz
saved_quiz = db.quizzes.find_one({"_id": quiz_result.inserted_id})
saved_quiz['_id'] = str(saved_quiz['_id'])
print(f"✅ Quiz generated with {len(saved_quiz.get('questions', []))} questions")
return jsonify({
"success": True,
"quiz": saved_quiz,
"message": f"Generated {len(saved_quiz.get('questions', []))} AI questions on topic: {topic}"
}), 201
except Exception as e:
print(f"❌ AI generation error: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500
@bp.route('/room/<room_code>/generate-ai-questions', methods=['POST', 'OPTIONS'])
def generate_ai_questions(room_code):
"""Generate AI questions for the quiz room - IMPROVED VERSION"""
@@ -1040,3 +1165,102 @@ def get_quiz_by_id(quiz_id):
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@bp.route('/<quiz_id>/submit', methods=['POST', 'OPTIONS'])
def submit_traditional_quiz(quiz_id):
"""Submit traditional quiz answers, store result, and log user activity."""
if request.method == "OPTIONS":
response = jsonify({'status': 'ok'})
response.headers.add("Access-Control-Allow-Origin", "*")
response.headers.add("Access-Control-Allow-Headers", "Content-Type,Authorization")
response.headers.add("Access-Control-Allow-Methods", "POST,OPTIONS")
return response
try:
db = get_db()
data = request.get_json() or {}
answers = data.get('answers') or {}
participant_name = (data.get('participant_name') or 'User').strip()
quiz = db.quizzes.find_one({"id": quiz_id})
if not quiz:
return jsonify({"success": False, "error": "Quiz not found"}), 404
questions = quiz.get('questions', [])
total_questions = len(questions)
correct_answers = 0
total_points = 0
ai_feedback = []
for idx, question in enumerate(questions):
question_id = question.get('id') or question.get('question_id') or f"q_{idx}"
expected = str(question.get('correct_answer', '')).strip().lower()
user_answer = str(answers.get(question_id, '')).strip()
is_correct = user_answer.lower() == expected if expected else False
points = int(question.get('points', 10) or 10)
if is_correct:
correct_answers += 1
total_points += points
ai_feedback.append({
"question": question.get('question_text', question.get('question', f"Question {idx + 1}")),
"user_answer": user_answer,
"is_correct": is_correct,
"correct_answer": question.get('correct_answer', ''),
"ai_feedback": {
"feedback": "Correct" if is_correct else "Review this concept and try again"
}
})
score = round((correct_answers / total_questions) * 100, 1) if total_questions > 0 else 0
identity = resolve_user_identity(request, db)
resolved_user_id = identity.get("user_id") or data.get("user_id") or data.get("wallet_address")
if isinstance(resolved_user_id, str):
resolved_user_id = resolved_user_id.strip().lower() if resolved_user_id.startswith("0x") else resolved_user_id.strip()
if resolved_user_id:
db.user_quizzes.insert_one({
"user_id": resolved_user_id,
"quiz_id": quiz_id,
"title": quiz.get('title', 'Quiz Submission'),
"topic": quiz.get('topic', 'General'),
"participant_name": participant_name,
"score": score,
"correct_answers": correct_answers,
"total_questions": total_questions,
"points": total_points,
"completed_at": datetime.now(),
"answers": answers,
})
log_user_activity(
db,
resolved_user_id,
"quiz",
"Completed quiz",
f"Completed quiz '{quiz.get('title', quiz_id)}' with score {score}%",
{
"quiz_id": quiz_id,
"quiz_title": quiz.get('title', 'Quiz'),
"score": score,
"correct_answers": correct_answers,
"total_questions": total_questions,
},
points_earned=total_points,
)
return jsonify({
"success": True,
"results": {
"score": score,
"correct_answers": correct_answers,
"total_questions": total_questions,
"total_points": total_points,
"ai_feedback": ai_feedback,
}
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
+1 -1
View File
@@ -61,7 +61,7 @@ def deploy_contract():
# Sign and send transaction
signed_txn = w3.eth.account.sign_transaction(transaction, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
print(f"Transaction hash: {tx_hash.hex()}")
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Simple deployment script for OpenLearnX smart contracts using Anvil
"""
import os
import json
from pathlib import Path
import subprocess
from web3 import Web3
from dotenv import load_dotenv
# Load environment variables
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
def deploy_contract():
provider_url = os.getenv('WEB3_PROVIDER_URL', 'http://127.0.0.1:8545')
# Connect to Web3
w3 = Web3(Web3.HTTPProvider(provider_url))
if not w3.is_connected():
raise Exception(f"Failed to connect to {provider_url}")
print(f"✓ Connected to {provider_url}")
print(f"Chain ID: {w3.eth.chain_id}")
# Use Anvil's first account (well-known test account)
# Get all accounts
accounts = w3.eth.accounts
if not accounts:
raise Exception("No accounts available in Anvil")
deployer = accounts[0]
balance = w3.eth.get_balance(deployer)
print(f"✓ Deployer account: {deployer}")
print(f"✓ Balance: {w3.from_wei(balance, 'ether')} ETH")
# Load contract
contract_path = BASE_DIR / "out" / "CertificateNFT.sol" / "CertificateNFT.json"
if not contract_path.exists():
print("❌ Contract JSON not found. Running forge build...")
result = subprocess.run(["forge", "build"], cwd=BASE_DIR, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"forge build failed: {result.stderr}")
with open(contract_path, 'r') as f:
contract_data = json.load(f)
print(f"✓ Loaded contract ABI and bytecode")
# Create contract
contract = w3.eth.contract(
abi=contract_data['abi'],
bytecode=contract_data['bytecode']['object']
)
# Deploy
print("⏳ Deploying contract...")
tx_hash = contract.constructor().transact({
'from': deployer,
'gas': 5000000,
'gasPrice': w3.to_wei('1', 'gwei')
})
print(f"✓ Transaction sent: {tx_hash.hex()}")
print("⏳ Waiting for receipt...")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
contract_address = receipt.contractAddress
print(f"\n✅ Contract deployed successfully!")
print(f"📍 Contract Address: {contract_address}")
print(f"⛽ Gas Used: {receipt.gasUsed}")
# Save deployment info
deployment_info = {
'contract_address': contract_address,
'transaction_hash': tx_hash.hex(),
'deployer': deployer,
'network': 'anvil',
'abi': contract_data['abi'],
'gas_used': receipt.gasUsed,
'block_number': receipt.blockNumber
}
deployment_file = BASE_DIR / "deployment.json"
with open(deployment_file, 'w') as f:
json.dump(deployment_info, f, indent=2)
print(f"✓ Deployment info saved to: {deployment_file}")
print(f"\n📝 Update your .env file:")
print(f"CONTRACT_ADDRESS={contract_address}")
return contract_address
if __name__ == '__main__':
try:
address = deploy_contract()
print(f"\n✨ Deployment complete!")
except Exception as e:
print(f"❌ Error: {e}")
exit(1)
+12 -4
View File
@@ -1,14 +1,22 @@
import tensorflow as tf
import pickle
import json
import numpy as np
import random
import os
from tensorflow.keras.preprocessing.sequence import pad_sequences
from datetime import datetime
from bson import ObjectId
import uuid
# Optional TensorFlow import with fallback
try:
import tensorflow as tf
from tensorflow.keras.preprocessing.sequence import pad_sequences
TENSORFLOW_AVAILABLE = True
except ImportError:
tf = None
pad_sequences = None
TENSORFLOW_AVAILABLE = False
class AdaptiveQuizMasterLLM:
def __init__(self, models_path="./models/"):
"""
@@ -18,13 +26,13 @@ class AdaptiveQuizMasterLLM:
self.model_available = False
try:
# Try to load model files
# Try to load model files only if TensorFlow is available
model_file = f'{models_path}improved_cnn_model.h5'
tokenizer_file = f'{models_path}tokenizer.pickle'
label_encoder_file = f'{models_path}label_encoder.pickle'
data_file = f'{models_path}processed_commonsenseqa_data.json'
if all(os.path.exists(f) for f in [model_file, tokenizer_file, label_encoder_file, data_file]):
if TENSORFLOW_AVAILABLE and all(os.path.exists(f) for f in [model_file, tokenizer_file, label_encoder_file, data_file]):
try:
self.model = tf.keras.models.load_model(model_file)
print("✅ CNN Model loaded successfully")
+30 -2
View File
@@ -13,10 +13,11 @@ import signal
class RealCompilerService:
def __init__(self):
self.client = docker.from_env()
self.client = None # Lazy initialization
self.execution_queue = queue.Queue()
self.active_executions = {}
self.max_concurrent_executions = 5
self.docker_available = False
# Enhanced language configurations with real execution
self.language_configs = {
@@ -97,6 +98,18 @@ class RealCompilerService:
# Start execution worker
self.start_execution_worker()
def _get_docker_client(self):
"""Lazily initialize Docker client"""
if self.client is None:
try:
self.client = docker.from_env()
self.docker_available = True
except Exception as e:
print(f"⚠️ Docker initialization failed: {e}")
self.docker_available = False
self.client = None
return self.client
def start_execution_worker(self):
"""Start background worker for code execution"""
def worker():
@@ -176,6 +189,17 @@ class RealCompilerService:
input_data = context['input_data']
config = context['config']
# Check Docker availability
docker_client = self._get_docker_client()
if docker_client is None or not self.docker_available:
return {
"output": "",
"error": "Docker service is not available. Compiler service cannot execute code.",
"exit_code": -1,
"execution_time": 0,
"memory_used": 0
}
with tempfile.TemporaryDirectory() as temp_dir:
# Prepare code file
filename = f"code{config['file_ext']}" if language != 'java' else "Main.java"
@@ -193,7 +217,7 @@ class RealCompilerService:
start_time = time.time()
# Create and run container
container = self.client.containers.run(
container = docker_client.containers.run(
config['image'],
command=self._build_execution_command(config, filename),
volumes={temp_dir: {'bind': '/app', 'mode': 'rw'}},
@@ -302,4 +326,8 @@ class RealCompilerService:
return False
# Create global instance
try:
real_compiler_service = RealCompilerService()
except Exception as e:
print(f"⚠️ Failed to initialize RealCompilerService: {e}")
real_compiler_service = RealCompilerService() # Still create instance for graceful fallback
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_BACKEND_URL=http://localhost:5000
NEXT_PUBLIC_WEB3_PROVIDER_URL=http://127.0.0.1:8545
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link"
export default function UnauthorizedPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-sky-100 dark:from-slate-900 dark:via-blue-950 dark:to-slate-900 p-6">
<section className="w-full max-w-xl rounded-2xl border border-blue-200/70 dark:border-blue-900 bg-white/90 dark:bg-slate-900/90 p-8 shadow-xl">
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 dark:text-blue-300">ERROR 401</p>
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Unauthorized</h1>
<p className="mt-3 text-sm text-slate-600 dark:text-slate-300">
You need to log in to access this page.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/auth/login"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
>
Login
</Link>
<Link
href="/"
className="rounded-lg border border-blue-300 px-4 py-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-950/50"
>
Go Home
</Link>
</div>
</section>
</main>
)
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link"
export default function ForbiddenPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-sky-100 dark:from-slate-900 dark:via-blue-950 dark:to-slate-900 p-6">
<section className="w-full max-w-xl rounded-2xl border border-blue-200/70 dark:border-blue-900 bg-white/90 dark:bg-slate-900/90 p-8 shadow-xl">
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 dark:text-blue-300">ERROR 403</p>
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Forbidden</h1>
<p className="mt-3 text-sm text-slate-600 dark:text-slate-300">
You do not have permission to access this page.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/dashboard"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
>
Back to Dashboard
</Link>
<Link
href="/"
className="rounded-lg border border-blue-300 px-4 py-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-950/50"
>
Go Home
</Link>
</div>
</section>
</main>
)
}
+7 -7
View File
@@ -152,7 +152,7 @@ export default function AdaptiveQuizPage() {
if (!quizStarted) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white flex items-center justify-center">
<div className="max-w-2xl mx-auto p-6 text-center">
<div className="mb-8">
<Brain className="h-16 w-16 text-purple-400 mx-auto mb-4" />
@@ -209,7 +209,7 @@ export default function AdaptiveQuizPage() {
if (quizCompleted) {
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
<div className="max-w-4xl mx-auto p-6">
<div className="text-center mb-8">
<Award className="h-16 w-16 text-yellow-400 mx-auto mb-4" />
@@ -252,16 +252,16 @@ export default function AdaptiveQuizPage() {
)}
{sessionStats && (
<div className="bg-gray-800 p-6 rounded-lg mb-6">
<h3 className="text-xl font-bold mb-4">Performance by Difficulty</h3>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6">
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Performance by Difficulty</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.entries(sessionStats.difficulty_breakdown).map(([difficulty, stats]) => (
<div key={difficulty} className="bg-gray-900 p-4 rounded">
<div key={difficulty} className="bg-gray-50 dark:bg-gray-900 p-4 rounded">
<div className={`px-2 py-1 rounded text-xs font-medium mb-2 ${getDifficultyColor(difficulty)}`}>
{difficulty.toUpperCase()}
</div>
<div className="text-lg font-bold">{stats.accuracy}%</div>
<div className="text-sm text-gray-400">
<div className="text-lg font-bold text-gray-900 dark:text-white">{stats.accuracy}%</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{stats.correct}/{stats.questions} questions
</div>
</div>
+295
View File
@@ -0,0 +1,295 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Edit, Plus, Trash2 } from "lucide-react"
type Course = {
id: string
title: string
subject: string
description: string
difficulty: string
mentor: string
video_url: string
students: number
}
const API_BASE = "http://127.0.0.1:5000"
export default function AdminCoursesPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [courses, setCourses] = useState<Course[]>([])
const [loading, setLoading] = useState(true)
const [showAdd, setShowAdd] = useState(false)
const [editing, setEditing] = useState<Course | null>(null)
const getToken = () => localStorage.getItem("admin_token")
const headers = () => {
const token = getToken()
return token
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
: { "Content-Type": "application/json" }
}
const ensureAuth = async () => {
const token = getToken()
if (!token) {
router.push("/admin/login")
return false
}
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
if (!resp.ok) {
localStorage.removeItem("admin_token")
router.push("/admin/login")
return false
}
return true
}
const fetchCourses = async () => {
setLoading(true)
try {
const resp = await fetch(`${API_BASE}/api/admin/courses`, { headers: headers() })
if (resp.ok) {
const data = await resp.json()
setCourses(Array.isArray(data) ? data : [])
} else {
setCourses([])
}
} catch {
setCourses([])
} finally {
setLoading(false)
}
}
const saveCourse = async (payload: Partial<Course>, courseId?: string) => {
const url = courseId ? `${API_BASE}/api/admin/courses/${courseId}` : `${API_BASE}/api/admin/courses`
const method = courseId ? "PUT" : "POST"
const resp = await fetch(url, {
method,
headers: headers(),
body: JSON.stringify(payload),
})
if (resp.ok) {
setShowAdd(false)
setEditing(null)
await fetchCourses()
} else {
const err = await resp.json().catch(() => ({ error: "Operation failed" }))
alert(err.error || "Operation failed")
}
}
const deleteCourse = async (courseId: string) => {
if (!confirm("Delete this course and related modules/lessons?")) return
const resp = await fetch(`${API_BASE}/api/admin/courses/${courseId}`, {
method: "DELETE",
headers: headers(),
})
if (resp.ok) {
await fetchCourses()
} else {
alert("Failed to delete course")
}
}
useEffect(() => {
const init = async () => {
const ok = await ensureAuth()
if (!ok) return
setReady(true)
await fetchCourses()
}
init()
}, [])
if (!ready) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-600 dark:text-gray-300">Loading course management...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Course Management</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">Manage real courses from database records.</p>
</div>
<button
onClick={() => setShowAdd(true)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
Add Course
</button>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Title</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Subject</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Difficulty</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Mentor</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Students</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loading ? (
<tr>
<td className="px-4 py-4 text-sm text-gray-600" colSpan={6}>Loading courses...</td>
</tr>
) : courses.length === 0 ? (
<tr>
<td className="px-4 py-4 text-sm text-gray-500" colSpan={6}>No courses found.</td>
</tr>
) : (
courses.map((course) => (
<tr key={course.id}>
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">{course.title}</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{course.subject}</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{course.difficulty}</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{course.mentor}</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{Number(course.students || 0).toLocaleString()}</td>
<td className="px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<button
onClick={() => setEditing(course)}
className="rounded p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/30"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => deleteCourse(course.id)}
className="rounded p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{(showAdd || editing) && (
<CourseFormModal
title={editing ? "Edit Course" : "Add Course"}
initialData={editing || undefined}
onClose={() => {
setShowAdd(false)
setEditing(null)
}}
onSubmit={(payload) => saveCourse(payload, editing?.id)}
/>
)}
</div>
)
}
function CourseFormModal({
title,
initialData,
onClose,
onSubmit,
}: {
title: string
initialData?: Partial<Course>
onClose: () => void
onSubmit: (payload: Partial<Course>) => Promise<void>
}) {
const [form, setForm] = useState<Partial<Course>>({
title: initialData?.title || "",
subject: initialData?.subject || "",
description: initialData?.description || "",
difficulty: initialData?.difficulty || "Beginner",
mentor: initialData?.mentor || "",
video_url: initialData?.video_url || "",
})
const [saving, setSaving] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
await onSubmit(form)
setSaving(false)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4">
<div className="w-full max-w-xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
<form onSubmit={submit} className="space-y-3">
<input
required
placeholder="Title"
value={form.title || ""}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
required
placeholder="Subject"
value={form.subject || ""}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<textarea
required
placeholder="Description"
value={form.description || ""}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
rows={3}
/>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<input
placeholder="Difficulty"
value={form.difficulty || ""}
onChange={(e) => setForm({ ...form, difficulty: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
placeholder="Mentor"
value={form.mentor || ""}
onChange={(e) => setForm({ ...form, mentor: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
</div>
<input
placeholder="Video URL"
value={form.video_url || ""}
onChange={(e) => setForm({ ...form, video_url: e.target.value })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="rounded-md bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" disabled={saving} className="rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-60">
{saving ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
)
}
+399
View File
@@ -0,0 +1,399 @@
"use client"
import { FormEvent, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
type CollectionInfo = { name: string; count: number }
type DocumentRow = Record<string, unknown>
const API_BASE = "http://127.0.0.1:5000"
export default function AdminDatabasePage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [collections, setCollections] = useState<CollectionInfo[]>([])
const [selectedCollection, setSelectedCollection] = useState<string>("")
const [documents, setDocuments] = useState<DocumentRow[]>([])
const [search, setSearch] = useState("")
const [loadingCollections, setLoadingCollections] = useState(false)
const [loadingDocuments, setLoadingDocuments] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState("")
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 1 })
const [createJson, setCreateJson] = useState('{\n "key": "value"\n}')
const [editDocId, setEditDocId] = useState("")
const [editJson, setEditJson] = useState("{}")
const getToken = () => localStorage.getItem("admin_token")
const headers = () => {
const token = getToken()
return token
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
: { "Content-Type": "application/json" }
}
const ensureAuth = async () => {
const token = getToken()
if (!token) {
router.push("/admin/login")
return false
}
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
if (!resp.ok) {
localStorage.removeItem("admin_token")
router.push("/admin/login")
return false
}
return true
}
const fetchOverview = async () => {
setLoadingCollections(true)
setMessage("")
try {
const resp = await fetch(`${API_BASE}/api/admin/database/overview`, { headers: headers() })
if (resp.ok) {
const data = await resp.json()
const list = Array.isArray(data.collections) ? data.collections : []
setCollections(list)
if (list.length > 0 && !selectedCollection) {
setSelectedCollection(list[0].name)
await fetchDocuments(list[0].name, 1)
}
} else {
const data = await resp.json().catch(() => ({}))
setMessage(String(data.error || "Failed to load collections."))
}
} catch {
setMessage("Network error while loading collections.")
} finally {
setLoadingCollections(false)
}
}
const fetchDocuments = async (collection: string, page = 1, nextSearch = search) => {
if (!collection) return
setLoadingDocuments(true)
setMessage("")
try {
const params = new URLSearchParams({ page: String(page), limit: String(pagination.limit) })
if (nextSearch.trim()) params.set("search", nextSearch.trim())
const resp = await fetch(`${API_BASE}/api/admin/database/collections/${encodeURIComponent(collection)}?${params.toString()}`, { headers: headers() })
if (resp.ok) {
const data = await resp.json()
setDocuments(Array.isArray(data.documents) ? data.documents : [])
if (data.pagination) setPagination(data.pagination)
} else {
const data = await resp.json().catch(() => ({}))
setMessage(String(data.error || "Failed to load documents."))
setDocuments([])
}
} catch {
setMessage("Network error while loading documents.")
setDocuments([])
} finally {
setLoadingDocuments(false)
}
}
const createDocument = async (e: FormEvent) => {
e.preventDefault()
if (!selectedCollection) return
setSaving(true)
setMessage("")
try {
const payload = JSON.parse(createJson)
const resp = await fetch(`${API_BASE}/api/admin/database/collections/${encodeURIComponent(selectedCollection)}`, {
method: "POST",
headers: headers(),
body: JSON.stringify(payload),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to create document."))
return
}
setMessage("Document created successfully.")
await fetchOverview()
await fetchDocuments(selectedCollection, 1)
} catch {
setMessage("Invalid JSON for create payload.")
} finally {
setSaving(false)
}
}
const startEditDocument = (doc: DocumentRow) => {
const id = String(doc._id || "")
if (!id) {
setMessage("Selected document has no _id.")
return
}
const clone = { ...doc }
delete clone._id
setEditDocId(id)
setEditJson(JSON.stringify(clone, null, 2))
}
const saveEditDocument = async (e: FormEvent) => {
e.preventDefault()
if (!selectedCollection || !editDocId) return
setSaving(true)
setMessage("")
try {
const payload = JSON.parse(editJson)
const resp = await fetch(
`${API_BASE}/api/admin/database/collections/${encodeURIComponent(selectedCollection)}/${encodeURIComponent(editDocId)}`,
{
method: "PUT",
headers: headers(),
body: JSON.stringify(payload),
},
)
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to update document."))
return
}
setMessage("Document updated successfully.")
setEditDocId("")
setEditJson("{}")
await fetchDocuments(selectedCollection, pagination.page)
} catch {
setMessage("Invalid JSON for update payload.")
} finally {
setSaving(false)
}
}
const deleteDocument = async (docId: string) => {
if (!selectedCollection || !docId) return
setSaving(true)
setMessage("")
try {
const resp = await fetch(
`${API_BASE}/api/admin/database/collections/${encodeURIComponent(selectedCollection)}/${encodeURIComponent(docId)}`,
{
method: "DELETE",
headers: headers(),
},
)
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to delete document."))
return
}
setMessage("Document deleted successfully.")
await fetchOverview()
await fetchDocuments(selectedCollection, 1)
} catch {
setMessage("Network error while deleting document.")
} finally {
setSaving(false)
}
}
useEffect(() => {
const init = async () => {
const ok = await ensureAuth()
if (!ok) return
setReady(true)
await fetchOverview()
}
init()
}, [])
if (!ready) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-600 dark:text-gray-300">Loading database explorer...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Database Explorer</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Browse all collections and perform create, update, and delete actions on documents.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<form
onSubmit={createDocument}
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Create Document</h3>
<p className="mb-2 text-xs text-gray-600 dark:text-gray-300">Collection: {selectedCollection || "None selected"}</p>
<textarea
value={createJson}
onChange={(e) => setCreateJson(e.target.value)}
rows={10}
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-800"
/>
<button
type="submit"
disabled={saving || !selectedCollection}
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{saving ? "Saving..." : "Create"}
</button>
</form>
<form
onSubmit={saveEditDocument}
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Edit Document</h3>
<p className="mb-2 text-xs text-gray-600 dark:text-gray-300">Document ID: {editDocId || "Select a document below"}</p>
<textarea
value={editJson}
onChange={(e) => setEditJson(e.target.value)}
rows={10}
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-800"
/>
<div className="mt-3 flex gap-2">
<button
type="submit"
disabled={saving || !selectedCollection || !editDocId}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
>
{saving ? "Saving..." : "Update"}
</button>
<button
type="button"
onClick={() => {
setEditDocId("")
setEditJson("{}")
}}
className="rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
>
Clear
</button>
</div>
</form>
</div>
{message ? (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
{message}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-4">
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Collections</h3>
{loadingCollections ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Loading...</p>
) : (
<div className="space-y-1">
{collections.map((item) => (
<button
key={item.name}
onClick={() => {
setSelectedCollection(item.name)
fetchDocuments(item.name, 1)
}}
className={`flex w-full items-center justify-between rounded px-3 py-2 text-sm ${
selectedCollection === item.name
? "bg-blue-600 text-white"
: "bg-gray-50 text-gray-800 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
}`}
>
<span className="truncate text-left">{item.name}</span>
<span className="ml-2 text-xs">{item.count}</span>
</button>
))}
</div>
)}
</div>
<div className="xl:col-span-3 rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center gap-3 border-b border-gray-100 p-4 dark:border-gray-800">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search inside documents"
className="min-w-[260px] flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<button
onClick={() => fetchDocuments(selectedCollection, 1)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Search
</button>
</div>
<div className="max-h-[72vh] overflow-auto p-4">
{loadingDocuments ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Loading documents...</p>
) : documents.length === 0 ? (
<p className="text-sm text-gray-500">No documents to show.</p>
) : (
<div className="space-y-3">
{documents.map((doc, idx) => (
<div
key={String(doc._id || idx)}
className="rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<button
onClick={() => startEditDocument(doc)}
className="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700"
>
Edit
</button>
<button
onClick={() => deleteDocument(String(doc._id || ""))}
className="rounded bg-red-700 px-2 py-1 text-white hover:bg-red-800"
>
Delete
</button>
<span className="text-[11px] text-gray-600 dark:text-gray-300">ID: {String(doc._id || "unknown")}</span>
</div>
<pre className="overflow-auto">{JSON.stringify(doc, null, 2)}</pre>
</div>
))}
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
<span>Page {pagination.page} of {pagination.pages} Total {pagination.total}</span>
<div className="flex gap-2">
<button
onClick={() => fetchDocuments(selectedCollection, Math.max(1, pagination.page - 1))}
disabled={pagination.page <= 1}
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
>
Previous
</button>
<button
onClick={() => fetchDocuments(selectedCollection, Math.min(pagination.pages, pagination.page + 1))}
disabled={pagination.page >= pagination.pages}
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
"use client"
import { FormEvent, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
const API_BASE = "http://127.0.0.1:5000"
type FirewallRule = {
id: string
name?: string
ip?: string
method?: string
path_pattern?: string
action?: string
enabled?: boolean
created_at?: string
}
type RuleForm = {
name: string
ip: string
method: string
path_pattern: string
action: "block" | "allow"
enabled: boolean
}
const initialRuleForm: RuleForm = {
name: "",
ip: "",
method: "",
path_pattern: "",
action: "block",
enabled: true,
}
export default function AdminFirewallPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [rules, setRules] = useState<FirewallRule[]>([])
const [message, setMessage] = useState("")
const [form, setForm] = useState<RuleForm>(initialRuleForm)
const getToken = () => localStorage.getItem("admin_token")
const headers = () => {
const token = getToken()
return token
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
: { "Content-Type": "application/json" }
}
const ensureAuth = async () => {
const token = getToken()
if (!token) {
router.push("/admin/login")
return false
}
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
if (!resp.ok) {
localStorage.removeItem("admin_token")
router.push("/admin/login")
return false
}
return true
}
const fetchRules = async () => {
setLoading(true)
setMessage("")
try {
const resp = await fetch(`${API_BASE}/api/admin/firewall/rules`, { headers: headers() })
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setRules([])
setMessage(String(data.error || "Failed to load firewall rules."))
return
}
setRules(Array.isArray(data.rules) ? data.rules : [])
} catch {
setRules([])
setMessage("Network error while loading firewall rules.")
} finally {
setLoading(false)
}
}
const createRule = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
setMessage("")
try {
const payload = {
name: form.name.trim(),
ip: form.ip.trim(),
method: form.method.trim().toUpperCase(),
path_pattern: form.path_pattern.trim(),
action: form.action,
enabled: form.enabled,
}
const resp = await fetch(`${API_BASE}/api/admin/firewall/rules`, {
method: "POST",
headers: headers(),
body: JSON.stringify(payload),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to create firewall rule."))
return
}
setForm(initialRuleForm)
setMessage("Firewall rule created.")
await fetchRules()
} catch {
setMessage("Network error while creating firewall rule.")
} finally {
setSaving(false)
}
}
const deleteRule = async (ruleId: string) => {
if (!ruleId) return
setMessage("")
try {
const resp = await fetch(`${API_BASE}/api/admin/firewall/rules/${encodeURIComponent(ruleId)}`, {
method: "DELETE",
headers: headers(),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to delete firewall rule."))
return
}
setMessage("Firewall rule deleted.")
await fetchRules()
} catch {
setMessage("Network error while deleting firewall rule.")
}
}
useEffect(() => {
const init = async () => {
const ok = await ensureAuth()
if (!ok) return
setReady(true)
await fetchRules()
}
init()
}, [])
if (!ready) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-600 dark:text-gray-300">Loading firewall manager...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Manual Firewall</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Add or remove manual allow/block rules by IP, method, and path pattern.
</p>
</div>
<form
onSubmit={createRule}
className="grid gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900 lg:grid-cols-6"
>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="Rule name"
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={form.ip}
onChange={(e) => setForm((p) => ({ ...p, ip: e.target.value }))}
placeholder="IP (optional)"
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={form.method}
onChange={(e) => setForm((p) => ({ ...p, method: e.target.value }))}
placeholder="Method (GET/POST...)"
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={form.path_pattern}
onChange={(e) => setForm((p) => ({ ...p, path_pattern: e.target.value }))}
placeholder="Path pattern (optional)"
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<select
value={form.action}
onChange={(e) => setForm((p) => ({ ...p, action: e.target.value as "block" | "allow" }))}
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="block">block</option>
<option value="allow">allow</option>
</select>
<button
type="submit"
disabled={saving}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{saving ? "Adding..." : "Add Rule"}
</button>
<label className="col-span-full inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((p) => ({ ...p, enabled: e.target.checked }))}
/>
Rule enabled
</label>
</form>
<div className="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Active Rules</h2>
{message ? <p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{message}</p> : null}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Name</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">IP</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Method</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Path Pattern</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Action</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Enabled</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Created</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
Loading rules...
</td>
</tr>
) : rules.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-4 text-sm text-gray-500">
No firewall rules configured.
</td>
</tr>
) : (
rules.map((rule) => (
<tr key={rule.id}>
<td className="px-3 py-2 text-xs text-gray-800 dark:text-gray-100">{rule.name || "-"}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.ip || "-"}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.method || "-"}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.path_pattern || "-"}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.action || "block"}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.enabled ? "true" : "false"}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{String(rule.created_at || "-")}</td>
<td className="px-3 py-2 text-xs">
<button
onClick={() => deleteRule(rule.id)}
className="rounded bg-red-700 px-2 py-1 text-white hover:bg-red-800"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
"use client"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useMemo, useState, type ComponentType } from "react"
import { BarChart3, BookOpen, Database, FileText, LayoutDashboard, LogOut, Shield, Users } from "lucide-react"
type NavItem = {
href: string
label: string
icon: ComponentType<{ className?: string }>
}
const navItems: NavItem[] = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/courses", label: "Courses", icon: BookOpen },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/logs", label: "Logs", icon: FileText },
{ href: "/admin/reports", label: "Reports", icon: BarChart3 },
{ href: "/admin/database", label: "Database", icon: Database },
{ href: "/admin/firewall", label: "Firewall", icon: Shield },
]
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const [ready, setReady] = useState(false)
const isLoginPage = useMemo(() => pathname === "/admin/login", [pathname])
useEffect(() => {
setReady(true)
}, [])
const handleLogout = () => {
localStorage.removeItem("admin_token")
router.push("/admin/login")
}
if (!ready) return null
if (isLoginPage) return <>{children}</>
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
<div className="flex w-full">
<aside className="sticky top-0 h-screen w-72 shrink-0 border-r border-gray-200 bg-white/90 p-5 backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/90">
<div className="mb-6 border-b border-gray-200 pb-4 dark:border-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">OpenLearnX Admin</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">Professional control panel</p>
</div>
<nav className="space-y-1">
{navItems.map((item) => {
const active = pathname === item.href
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800"
}`}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
)
})}
</nav>
<div className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/40">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<BarChart3 className="h-4 w-4" />
<p className="text-xs font-medium">Live Data Enabled</p>
</div>
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
Stats, logs, and actions are loaded directly from backend collections.
</p>
</div>
<button
onClick={handleLogout}
className="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</aside>
<section className="min-w-0 flex-1 p-5 lg:p-7">{children}</section>
</div>
</div>
)
}
+4 -4
View File
@@ -137,7 +137,7 @@ export default function AdminLogin() {
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded text-sm">
{error}
{error}
</div>
)}
@@ -153,7 +153,7 @@ export default function AdminLogin() {
Authenticating...
</div>
) : (
'🔐 Login to Admin Panel'
'Login to Admin Panel'
)}
</button>
</form>
@@ -162,7 +162,7 @@ export default function AdminLogin() {
<div className="mt-6 pt-4 border-t border-gray-100">
<div className="text-center">
<p className="text-xs text-gray-500">
🔒 Secure access only - Contact administrator for credentials
Secure access only - Contact administrator for credentials
</p>
</div>
</div>
@@ -171,7 +171,7 @@ export default function AdminLogin() {
{/* Footer */}
<div className="text-center mt-4">
<p className="text-sm text-gray-500">
Welcome back, <span className="font-medium text-gray-700">5t4l1n</span>! 👋
Welcome back, <span className="font-medium text-gray-700">5t4l1n</span>
</p>
</div>
</div>
+467
View File
@@ -0,0 +1,467 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
type AdminLog = {
id: string
timestamp: string
event_type: string
action: string
status_code: number
severity: string
method: string
ip: string
path: string
user_agent?: string
metadata?: Record<string, unknown>
request_body?: unknown
response_body?: unknown
usage?: Record<string, unknown>
query?: Record<string, unknown>
duration_ms?: number
origin?: string
}
const API_BASE = "http://127.0.0.1:5000"
export default function AdminLogsPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [message, setMessage] = useState("")
const [logs, setLogs] = useState<AdminLog[]>([])
const [selectedLog, setSelectedLog] = useState<AdminLog | null>(null)
const safeJson = (value: unknown) => {
if (value === null || value === undefined || value === "") return "No data"
if (typeof value === "string") {
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
const copyText = async (value: string) => {
try {
await navigator.clipboard.writeText(value)
setMessage("Copied to clipboard")
} catch {
setMessage("Copy failed")
}
}
const selectedRequestData = selectedLog
? selectedLog.request_body
?? (selectedLog.metadata && (selectedLog.metadata as any).request_body)
?? (selectedLog.metadata && (selectedLog.metadata as any).request_details)
?? selectedLog.query
?? null
: null
const selectedResponseData = selectedLog
? selectedLog.response_body
?? (selectedLog.metadata && (selectedLog.metadata as any).response_body)
?? (selectedLog.metadata && (selectedLog.metadata as any).response_details)
?? null
: null
const selectedUsageData = selectedLog
? selectedLog.usage
?? (selectedLog.metadata && (selectedLog.metadata as any).usage)
?? {
duration_ms: selectedLog.duration_ms ?? 0,
note: "Usage metrics not captured for this log entry",
}
: null
const [filters, setFilters] = useState({
event_type: "",
severity: "",
status_code: "",
search: "",
})
const [pagination, setPagination] = useState({ page: 1, limit: 50, total: 0, pages: 1 })
const getToken = () => localStorage.getItem("admin_token")
const headers = () => {
const token = getToken()
return token
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
: { "Content-Type": "application/json" }
}
const ensureAuth = async () => {
const token = getToken()
if (!token) {
router.push("/admin/login")
return false
}
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
if (!resp.ok) {
localStorage.removeItem("admin_token")
router.push("/admin/login")
return false
}
return true
}
const fetchLogs = async (page = 1, nextFilters = filters) => {
setLoading(true)
setMessage("")
const params = new URLSearchParams()
params.set("page", String(page))
params.set("limit", String(pagination.limit))
if (nextFilters.event_type) params.set("event_type", nextFilters.event_type)
if (nextFilters.severity) params.set("severity", nextFilters.severity)
if (nextFilters.status_code) params.set("status_code", nextFilters.status_code)
if (nextFilters.search) params.set("search", nextFilters.search)
try {
const resp = await fetch(`${API_BASE}/api/admin/logs?${params.toString()}`, { headers: headers() })
if (resp.ok) {
const data = await resp.json()
setLogs(Array.isArray(data.logs) ? data.logs : [])
if (data.pagination) {
setPagination(data.pagination)
}
} else {
setLogs([])
}
} catch {
setLogs([])
} finally {
setLoading(false)
}
}
const triggerDownload = (content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
const exportLogs = async (format: "json" | "csv") => {
setExporting(true)
setMessage("")
const params = new URLSearchParams()
params.set("type", "logs")
params.set("format", format)
params.set("limit", "5000")
if (filters.event_type) params.set("event_type", filters.event_type)
if (filters.severity) params.set("severity", filters.severity)
if (filters.status_code) params.set("status_code", filters.status_code)
if (filters.search) params.set("search", filters.search)
try {
const resp = await fetch(`${API_BASE}/api/admin/reports/export?${params.toString()}`, { headers: headers() })
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to export logs."))
return
}
const stamp = new Date().toISOString().replace(/[.:]/g, "-")
if (format === "json") {
triggerDownload(JSON.stringify(data, null, 2), `admin-logs-${stamp}.json`, "application/json")
} else {
triggerDownload(String(data.content || ""), `admin-logs-${stamp}.csv`, "text/csv")
}
setMessage(`Logs exported as ${format.toUpperCase()}.`)
} catch {
setMessage("Network error while exporting logs.")
} finally {
setExporting(false)
}
}
useEffect(() => {
const init = async () => {
const ok = await ensureAuth()
if (!ok) return
setReady(true)
await fetchLogs(1)
}
init()
}, [])
if (!ready) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-600 dark:text-gray-300">Loading logs...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Security and Activity Logs</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Filter authentication, access-control, suspicious payload, and admin activity events.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={() => exportLogs("json")}
disabled={exporting}
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
>
Export Logs JSON
</button>
<button
onClick={() => exportLogs("csv")}
disabled={exporting}
className="rounded-md bg-emerald-700 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-800 disabled:opacity-60"
>
Export Logs CSV
</button>
</div>
{message ? <p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{message}</p> : null}
</div>
<div className="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="grid grid-cols-1 gap-3 border-b border-gray-100 p-4 md:grid-cols-6 dark:border-gray-800">
<input
placeholder="Search action, path, IP"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="md:col-span-2 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<select
value={filters.event_type}
onChange={(e) => setFilters({ ...filters, event_type: e.target.value })}
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="">All Event Types</option>
<option value="admin_panel">Admin Panel</option>
<option value="admin_panel_visit">Admin Visit</option>
<option value="signin">Sign In</option>
<option value="signup">Sign Up</option>
<option value="course_join">Course Join</option>
<option value="attendance">Attendance</option>
<option value="forbidden_access">403 Forbidden</option>
<option value="suspicious_payload">Suspicious Payload</option>
</select>
<select
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="">All Severity</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input
placeholder="Status code"
value={filters.status_code}
onChange={(e) => setFilters({ ...filters, status_code: e.target.value })}
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<div className="flex gap-2">
<button
onClick={() => fetchLogs(1)}
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Apply
</button>
<button
onClick={() => {
const reset = { event_type: "", severity: "", status_code: "", search: "" }
setFilters(reset)
fetchLogs(1, reset)
}}
className="w-full rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100"
>
Clear
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Time</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Event</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Action</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">IP</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Path</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loading ? (
<tr>
<td className="px-4 py-4 text-sm text-gray-600" colSpan={6}>Loading logs...</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td className="px-4 py-4 text-sm text-gray-500" colSpan={6}>No logs found for selected filters.</td>
</tr>
) : (
logs.map((log) => (
<tr
key={log.id}
onClick={() => setSelectedLog(log)}
className="cursor-pointer hover:bg-blue-50 dark:hover:bg-gray-800/60"
title="Click to view request and response details"
>
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{new Date(log.timestamp).toLocaleString()}</td>
<td className="px-4 py-3 text-xs font-medium text-gray-900 dark:text-white">{log.event_type}</td>
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
<td className="px-4 py-3 text-xs">
<span className={`rounded px-2 py-1 ${log.status_code >= 400 ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}>
{log.status_code}
</span>
</td>
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{log.ip}</td>
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{log.path}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
<span>
Page {pagination.page} of {pagination.pages} Total {pagination.total}
</span>
<div className="flex gap-2">
<button
onClick={() => fetchLogs(Math.max(1, pagination.page - 1))}
disabled={pagination.page <= 1}
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
>
Previous
</button>
<button
onClick={() => fetchLogs(Math.min(pagination.pages, pagination.page + 1))}
disabled={pagination.page >= pagination.pages}
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
>
Next
</button>
</div>
</div>
</div>
{selectedLog ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-4xl rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between border-b border-gray-100 p-4 dark:border-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Log Request and Response Details</h2>
<button
onClick={() => setSelectedLog(null)}
className="rounded-md bg-gray-200 px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100"
>
Close
</button>
</div>
<div className="max-h-[75vh] space-y-4 overflow-auto p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Event</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{selectedLog.event_type}</p>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Action</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{selectedLog.action}</p>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Path</p>
<p className="mt-1 text-sm break-all text-gray-900 dark:text-white">{selectedLog.path}</p>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Method and Status</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{selectedLog.method} {selectedLog.status_code}</p>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Duration</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">
{selectedLog.duration_ms ?? (selectedLog.metadata && (selectedLog.metadata as any).duration_ms) ?? 0} ms
</p>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Client</p>
<p className="mt-1 text-sm break-all text-gray-900 dark:text-white">{selectedLog.ip}</p>
<p className="mt-1 text-xs break-all text-gray-600 dark:text-gray-300">{selectedLog.user_agent || "Unknown user agent"}</p>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-gray-500">Request Body</p>
<button
onClick={() => copyText(safeJson(selectedRequestData))}
className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700"
>
Copy
</button>
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
{safeJson(selectedRequestData)}
</pre>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-gray-500">Response Body</p>
<button
onClick={() => copyText(safeJson(selectedResponseData))}
className="rounded bg-emerald-600 px-2 py-1 text-xs text-white hover:bg-emerald-700"
>
Copy
</button>
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
{safeJson(selectedResponseData)}
</pre>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<p className="text-xs font-semibold uppercase text-gray-500">Usage Monitoring</p>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
{safeJson(selectedUsageData)}
</pre>
</div>
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase text-gray-500">Full Metadata</p>
<button
onClick={() => copyText(safeJson(selectedLog.metadata ?? {}))}
className="rounded bg-gray-700 px-2 py-1 text-xs text-white hover:bg-gray-800"
>
Copy
</button>
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
{safeJson(selectedLog.metadata ?? {})}
</pre>
</div>
</div>
</div>
</div>
) : null}
</div>
)
}
+237 -1115
View File
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
const API_BASE = "http://127.0.0.1:5000"
type UsageReport = Record<string, string | number>
type SecurityReport = {
generated_at?: string
login_attempts?: number
suspicious_events?: number
error_events?: number
blocked_events?: number
top_ips?: Array<{ ip: string; count: number }>
}
export default function AdminReportsPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [message, setMessage] = useState("")
const [usageReport, setUsageReport] = useState<UsageReport>({})
const [securityReport, setSecurityReport] = useState<SecurityReport>({})
const getToken = () => localStorage.getItem("admin_token")
const headers = () => {
const token = getToken()
return token
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
: { "Content-Type": "application/json" }
}
const ensureAuth = async () => {
const token = getToken()
if (!token) {
router.push("/admin/login")
return false
}
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
if (!resp.ok) {
localStorage.removeItem("admin_token")
router.push("/admin/login")
return false
}
return true
}
const fetchReports = async () => {
setLoading(true)
setMessage("")
try {
const [usageResp, securityResp] = await Promise.all([
fetch(`${API_BASE}/api/admin/reports/usage`, { headers: headers() }),
fetch(`${API_BASE}/api/admin/reports/security`, { headers: headers() }),
])
const usageData = await usageResp.json().catch(() => ({}))
const securityData = await securityResp.json().catch(() => ({}))
if (!usageResp.ok || !securityResp.ok) {
setMessage(String(usageData.error || securityData.error || "Failed to fetch reports."))
return
}
setUsageReport(usageData.report || {})
setSecurityReport(securityData.report || {})
} catch {
setMessage("Network error while fetching reports.")
} finally {
setLoading(false)
}
}
const triggerDownload = (content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
const exportReport = async (reportType: "usage" | "security", format: "json" | "csv") => {
setExporting(true)
setMessage("")
try {
const resp = await fetch(
`${API_BASE}/api/admin/reports/export?type=${encodeURIComponent(reportType)}&format=${encodeURIComponent(format)}`,
{ headers: headers() },
)
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Export failed."))
return
}
const stamp = new Date().toISOString().replace(/[.:]/g, "-")
if (format === "json") {
triggerDownload(JSON.stringify(data, null, 2), `${reportType}-report-${stamp}.json`, "application/json")
} else {
triggerDownload(String(data.content || "key,value\n"), `${reportType}-report-${stamp}.csv`, "text/csv")
}
setMessage(`${reportType} report exported as ${format.toUpperCase()}.`)
} catch {
setMessage("Network error while exporting report.")
} finally {
setExporting(false)
}
}
useEffect(() => {
const init = async () => {
const ok = await ensureAuth()
if (!ok) return
setReady(true)
await fetchReports()
}
init()
}, [])
if (!ready) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-600 dark:text-gray-300">Loading reports...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Reports and Analytics</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Usage and security reporting with downloadable JSON and CSV exports.
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => fetchReports()}
disabled={loading}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{loading ? "Refreshing..." : "Refresh Reports"}
</button>
<button
onClick={() => exportReport("usage", "json")}
disabled={exporting}
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
>
Export Usage JSON
</button>
<button
onClick={() => exportReport("usage", "csv")}
disabled={exporting}
className="rounded-md bg-emerald-700 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-800 disabled:opacity-60"
>
Export Usage CSV
</button>
<button
onClick={() => exportReport("security", "json")}
disabled={exporting}
className="rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
Export Security JSON
</button>
<button
onClick={() => exportReport("security", "csv")}
disabled={exporting}
className="rounded-md bg-indigo-700 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-800 disabled:opacity-60"
>
Export Security CSV
</button>
</div>
{message ? <p className="mt-3 text-sm text-gray-700 dark:text-gray-200">{message}</p> : null}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Usage Report</h2>
{loading ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Loading usage report...</p>
) : (
<div className="space-y-2">
{Object.entries(usageReport).map(([key, value]) => (
<div key={key} className="flex items-center justify-between rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
<span className="text-gray-600 dark:text-gray-300">{key}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{String(value)}</span>
</div>
))}
{Object.keys(usageReport).length === 0 ? (
<p className="text-sm text-gray-500">No usage data available.</p>
) : null}
</div>
)}
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Security Report</h2>
{loading ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Loading security report...</p>
) : (
<div className="space-y-2">
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
<span className="text-gray-600 dark:text-gray-300">Login attempts:</span>{" "}
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.login_attempts || 0}</span>
</div>
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
<span className="text-gray-600 dark:text-gray-300">Suspicious events:</span>{" "}
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.suspicious_events || 0}</span>
</div>
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
<span className="text-gray-600 dark:text-gray-300">Error events:</span>{" "}
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.error_events || 0}</span>
</div>
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
<span className="text-gray-600 dark:text-gray-300">Blocked by firewall:</span>{" "}
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.blocked_events || 0}</span>
</div>
<div className="rounded border border-gray-100 p-3 text-sm dark:border-gray-800">
<p className="mb-2 font-medium text-gray-900 dark:text-gray-100">Top Source IPs</p>
<div className="space-y-1">
{(securityReport.top_ips || []).map((entry) => (
<div key={`${entry.ip}-${entry.count}`} className="flex items-center justify-between text-xs">
<span className="text-gray-600 dark:text-gray-300">{entry.ip}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{entry.count}</span>
</div>
))}
{(securityReport.top_ips || []).length === 0 ? (
<p className="text-xs text-gray-500">No IP analytics available.</p>
) : null}
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
+621
View File
@@ -0,0 +1,621 @@
"use client"
import { FormEvent, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
type UserDoc = Record<string, unknown>
type UserFormState = {
email: string
username: string
name: string
wallet_address: string
role: string
password: string
}
const API_BASE = "http://127.0.0.1:5000"
const initialUserForm: UserFormState = {
email: "",
username: "",
name: "",
wallet_address: "",
role: "student",
password: "",
}
const getUserId = (user: UserDoc) => String(user._id || user.id || "")
export default function AdminUsersPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<string>("")
const [users, setUsers] = useState<UserDoc[]>([])
const [selectedUser, setSelectedUser] = useState<UserDoc | null>(null)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [roleFilter, setRoleFilter] = useState("all")
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 1 })
const [message, setMessage] = useState("")
const [createForm, setCreateForm] = useState<UserFormState>(initialUserForm)
const [editMode, setEditMode] = useState(false)
const [editId, setEditId] = useState("")
const [editForm, setEditForm] = useState<UserFormState>(initialUserForm)
const getToken = () => localStorage.getItem("admin_token")
const headers = () => {
const token = getToken()
return token
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
: { "Content-Type": "application/json" }
}
const ensureAuth = async () => {
const token = getToken()
if (!token) {
router.push("/admin/login")
return false
}
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
if (!resp.ok) {
localStorage.removeItem("admin_token")
router.push("/admin/login")
return false
}
return true
}
const fetchUsers = async (page = 1, nextSearch = search, nextStatus = statusFilter, nextRole = roleFilter) => {
setLoading(true)
try {
const params = new URLSearchParams({
page: String(page),
limit: String(pagination.limit),
})
if (nextSearch.trim()) params.set("search", nextSearch.trim())
if (nextStatus !== "all") params.set("status", nextStatus)
if (nextRole !== "all") params.set("role", nextRole)
const resp = await fetch(`${API_BASE}/api/admin/users?${params.toString()}`, { headers: headers() })
if (!resp.ok) {
setUsers([])
setMessage("Failed to load users.")
return
}
const data = await resp.json()
setUsers(Array.isArray(data.users) ? data.users : [])
if (data.pagination) setPagination(data.pagination)
setMessage("")
} catch {
setUsers([])
setMessage("Network error while loading users.")
} finally {
setLoading(false)
}
}
const handleCreateUser = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
setMessage("")
try {
const payload: Record<string, unknown> = {
email: createForm.email.trim(),
username: createForm.username.trim(),
name: createForm.name.trim(),
wallet_address: createForm.wallet_address.trim(),
role: createForm.role,
}
if (createForm.password.trim()) payload.password = createForm.password
const resp = await fetch(`${API_BASE}/api/admin/users`, {
method: "POST",
headers: headers(),
body: JSON.stringify(payload),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to create user."))
return
}
setCreateForm(initialUserForm)
setMessage("User created successfully.")
await fetchUsers(1)
} catch {
setMessage("Network error while creating user.")
} finally {
setSaving(false)
}
}
const startEdit = (user: UserDoc) => {
setEditMode(true)
setEditId(getUserId(user))
setEditForm({
email: String(user.email || ""),
username: String(user.username || ""),
name: String(user.name || ""),
wallet_address: String(user.wallet_address || ""),
role: String(user.role || "student"),
password: "",
})
}
const submitEdit = async (e: FormEvent) => {
e.preventDefault()
if (!editId) return
setSaving(true)
setMessage("")
try {
const payload: Record<string, unknown> = {
email: editForm.email.trim(),
username: editForm.username.trim(),
name: editForm.name.trim(),
wallet_address: editForm.wallet_address.trim(),
role: editForm.role,
}
if (editForm.password.trim()) payload.password = editForm.password
const resp = await fetch(`${API_BASE}/api/admin/users/${editId}`, {
method: "PUT",
headers: headers(),
body: JSON.stringify(payload),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Failed to update user."))
return
}
setEditMode(false)
setEditId("")
setEditForm(initialUserForm)
setMessage("User updated successfully.")
await fetchUsers(pagination.page)
} catch {
setMessage("Network error while updating user.")
} finally {
setSaving(false)
}
}
const quickAction = async (
userId: string,
action: "suspend" | "ban" | "activate" | "delete" | "reset-password",
role?: string,
) => {
if (!userId) return
setActionLoadingId(`${userId}:${action}`)
setMessage("")
try {
let endpoint = `${API_BASE}/api/admin/users/${userId}/${action}`
let method: "POST" | "DELETE" = "POST"
let body: string | undefined
if (action === "delete") method = "DELETE"
if (action === "reset-password") body = JSON.stringify({ new_password: "TempPass@123" })
if (action === "suspend" || action === "ban" || action === "activate") {
endpoint = `${API_BASE}/api/admin/users/${userId}/status`
const statusMap: Record<string, string> = { suspend: "suspended", ban: "banned", activate: "active" }
body = JSON.stringify({ status: statusMap[action] })
}
if (role) {
endpoint = `${API_BASE}/api/admin/users/${userId}/role`
body = JSON.stringify({ role })
}
const resp = await fetch(endpoint, {
method,
headers: headers(),
body,
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setMessage(String(data.error || "Action failed."))
return
}
setMessage(role ? `Role updated to ${role}.` : `User action ${action} completed.`)
await fetchUsers(pagination.page)
} catch {
setMessage("Network error while running action.")
} finally {
setActionLoadingId("")
}
}
const roleSet = useMemo(() => {
const roles = new Set<string>()
for (const user of users) {
const value = String(user.role || "").trim()
if (value) roles.add(value)
}
return Array.from(roles)
}, [users])
useEffect(() => {
const init = async () => {
const ok = await ensureAuth()
if (!ok) return
setReady(true)
await fetchUsers(1)
}
init()
}, [])
if (!ready) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-600 dark:text-gray-300">Loading users...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Manage accounts, roles, access status, and student progress from real database records.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<form
onSubmit={handleCreateUser}
className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Create User</h2>
<input
value={createForm.email}
onChange={(e) => setCreateForm((p) => ({ ...p, email: e.target.value }))}
placeholder="Email"
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={createForm.username}
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
placeholder="Username"
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={createForm.name}
onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
placeholder="Full name"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={createForm.wallet_address}
onChange={(e) => setCreateForm((p) => ({ ...p, wallet_address: e.target.value }))}
placeholder="Wallet address"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<select
value={createForm.role}
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="student">student</option>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
</select>
<input
value={createForm.password}
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
placeholder="Password (optional)"
type="password"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<button
type="submit"
disabled={saving}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{saving ? "Saving..." : "Create User"}
</button>
</form>
<form
onSubmit={submitEdit}
className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Edit User</h2>
{!editMode ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Select a user from the table to edit details.</p>
) : (
<>
<input
value={editForm.email}
onChange={(e) => setEditForm((p) => ({ ...p, email: e.target.value }))}
placeholder="Email"
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={editForm.username}
onChange={(e) => setEditForm((p) => ({ ...p, username: e.target.value }))}
placeholder="Username"
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={editForm.name}
onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))}
placeholder="Full name"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<input
value={editForm.wallet_address}
onChange={(e) => setEditForm((p) => ({ ...p, wallet_address: e.target.value }))}
placeholder="Wallet address"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<select
value={editForm.role}
onChange={(e) => setEditForm((p) => ({ ...p, role: e.target.value }))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="student">student</option>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
</select>
<input
value={editForm.password}
onChange={(e) => setEditForm((p) => ({ ...p, password: e.target.value }))}
placeholder="Set new password (optional)"
type="password"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<button
type="button"
onClick={() => {
setEditMode(false)
setEditId("")
setEditForm(initialUserForm)
}}
className="rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</>
)}
</form>
</div>
<div className="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center gap-2 border-b border-gray-100 p-4 dark:border-gray-800">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search email, username, full name"
className="min-w-[220px] flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="all">All status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="banned">Banned</option>
</select>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="all">All roles</option>
<option value="student">student</option>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
{roleSet
.filter((r) => r !== "student" && r !== "instructor" && r !== "admin")
.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<button
onClick={() => fetchUsers(1, search, statusFilter, roleFilter)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Apply
</button>
</div>
{message ? (
<div className="border-b border-gray-100 px-4 py-2 text-sm text-gray-700 dark:border-gray-800 dark:text-gray-200">
{message}
</div>
) : null}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Email</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Username</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Role</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Status</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Progress</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
Loading users...
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-4 text-sm text-gray-500">
No users found.
</td>
</tr>
) : (
users.map((user, idx) => {
const userId = getUserId(user)
const status = String(user.status || "active")
const progress = Number(user.progress_percent || user.progress || 0)
return (
<tr key={userId || String(idx)}>
<td className="px-3 py-2 text-xs text-gray-800 dark:text-gray-100">{String(user.email || "-")}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{String(user.username || user.name || "-")}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{String(user.role || "student")}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{status}</td>
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{Number.isFinite(progress) ? `${progress}%` : "0%"}</td>
<td className="px-3 py-2 text-xs">
<div className="flex flex-wrap gap-1">
<button
onClick={() => setSelectedUser(user)}
className="rounded bg-gray-100 px-2 py-1 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
>
View
</button>
<button
onClick={() => startEdit(user)}
className="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700"
>
Edit
</button>
<button
disabled={actionLoadingId === `${userId}:suspend`}
onClick={() => quickAction(userId, "suspend")}
className="rounded bg-amber-600 px-2 py-1 text-white hover:bg-amber-700 disabled:opacity-60"
>
Suspend
</button>
<button
disabled={actionLoadingId === `${userId}:ban`}
onClick={() => quickAction(userId, "ban")}
className="rounded bg-rose-600 px-2 py-1 text-white hover:bg-rose-700 disabled:opacity-60"
>
Ban
</button>
<button
disabled={actionLoadingId === `${userId}:activate`}
onClick={() => quickAction(userId, "activate")}
className="rounded bg-emerald-600 px-2 py-1 text-white hover:bg-emerald-700 disabled:opacity-60"
>
Activate
</button>
<button
disabled={actionLoadingId === `${userId}:reset-password`}
onClick={() => quickAction(userId, "reset-password")}
className="rounded bg-purple-600 px-2 py-1 text-white hover:bg-purple-700 disabled:opacity-60"
>
Reset Password
</button>
<button
onClick={() => quickAction(userId, "activate", "student")}
className="rounded bg-slate-600 px-2 py-1 text-white hover:bg-slate-700"
>
Set Student
</button>
<button
onClick={() => quickAction(userId, "activate", "instructor")}
className="rounded bg-slate-600 px-2 py-1 text-white hover:bg-slate-700"
>
Set Instructor
</button>
<button
onClick={() => quickAction(userId, "activate", "admin")}
className="rounded bg-slate-900 px-2 py-1 text-white hover:bg-black"
>
Set Admin
</button>
<button
disabled={actionLoadingId === `${userId}:delete`}
onClick={() => quickAction(userId, "delete")}
className="rounded bg-red-800 px-2 py-1 text-white hover:bg-red-900 disabled:opacity-60"
>
Delete
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
<span>
Page {pagination.page} of {pagination.pages} | Total {pagination.total}
</span>
<div className="flex gap-2">
<button
onClick={() => fetchUsers(Math.max(1, pagination.page - 1), search, statusFilter, roleFilter)}
disabled={pagination.page <= 1}
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
>
Previous
</button>
<button
onClick={() => fetchUsers(Math.min(pagination.pages, pagination.page + 1), search, statusFilter, roleFilter)}
disabled={pagination.page >= pagination.pages}
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
>
Next
</button>
</div>
</div>
</div>
{selectedUser ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl rounded-xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-800 dark:bg-gray-900">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Full User Document</h3>
<button
onClick={() => setSelectedUser(null)}
className="rounded bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
Close
</button>
</div>
<pre className="max-h-[70vh] overflow-auto rounded bg-gray-50 p-3 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-100">
{JSON.stringify(selectedUser, null, 2)}
</pre>
</div>
</div>
) : null}
</div>
)
}
+36 -8
View File
@@ -10,15 +10,19 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Wallet, Mail, Lock, Loader2, CheckCircle2 } from "lucide-react"
import { toast } from "react-hot-toast"
import Link from "next/link"
import { MetaMaskEmailModal } from "@/components/metamask-email-modal"
export default function LoginPage() {
const {
user,
firebaseUser,
walletConnected,
walletAddress,
isLoadingAuth,
authMethod,
token,
showMetaMaskEmailModal,
setShowMetaMaskEmailModal,
connectWallet,
loginWithEmail
} = useAuth()
@@ -36,7 +40,6 @@ export default function LoginPage() {
isLoadingAuth,
hasRedirected: hasRedirected.current,
user: !!user,
firebaseUser: !!firebaseUser,
walletConnected,
walletAddress,
authMethod
@@ -50,12 +53,12 @@ export default function LoginPage() {
// Check for successful authentication
const isMetaMaskAuth = walletConnected && walletAddress && user && authMethod === "metamask"
const isFirebaseAuth = firebaseUser && authMethod === "firebase"
const isAuthenticated = isMetaMaskAuth || isFirebaseAuth
const isEmailAuth = user && authMethod === "email"
const isAuthenticated = isMetaMaskAuth || isEmailAuth
console.log("🔍 Authentication check:", {
isMetaMaskAuth,
isFirebaseAuth,
isEmailAuth,
isAuthenticated
})
@@ -70,7 +73,6 @@ export default function LoginPage() {
}
}, [
user,
firebaseUser,
walletConnected,
walletAddress,
authMethod,
@@ -122,7 +124,7 @@ export default function LoginPage() {
}
// ✅ Show success state when authenticated but not yet redirected
const isAuthenticated = (walletConnected && walletAddress && user) || firebaseUser
const isAuthenticated = (walletConnected && walletAddress && user) || (user && authMethod === "email")
if (isAuthenticated && !hasRedirected.current) {
return (
@@ -138,7 +140,7 @@ export default function LoginPage() {
<p className="text-gray-700">
{authMethod === "metamask"
? `🦊 MetaMask connected: ${walletAddress?.slice(0, 6)}...${walletAddress?.slice(-4)}`
: `📧 Email: ${firebaseUser?.email}`
: `📧 Email: ${user?.email || user?.id}`
}
</p>
<div className="flex items-center justify-center space-x-2">
@@ -264,8 +266,34 @@ export default function LoginPage() {
</form>
)}
</div>
<div className="text-center pt-4 border-t">
<p className="text-sm text-gray-600">
Don't have an account?{" "}
<Link href="/auth/signup" className="text-purple-600 hover:text-purple-700 font-semibold">
Sign Up
</Link>
</p>
</div>
</CardContent>
</Card>
{/* MetaMask Email Modal */}
{token && walletAddress && (
<MetaMaskEmailModal
isOpen={showMetaMaskEmailModal}
walletAddress={walletAddress}
token={token}
onSuccess={(user) => {
setShowMetaMaskEmailModal(false)
toast.success("Profile setup complete!")
}}
onCancel={() => {
setShowMetaMaskEmailModal(false)
// User can always add email later from dashboard
}}
/>
)}
</div>
)
}
+243
View File
@@ -0,0 +1,243 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/context/auth-context"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Wallet, Mail, Lock, Loader2, CheckCircle2 } from "lucide-react"
import { toast } from "react-hot-toast"
import Link from "next/link"
import { MetaMaskEmailModal } from "@/components/metamask-email-modal"
export default function SignupPage() {
const {
user,
walletConnected,
walletAddress,
isLoadingAuth,
authMethod,
token,
showMetaMaskEmailModal,
setShowMetaMaskEmailModal,
connectWallet,
signupWithEmail
} = useAuth()
const router = useRouter()
const hasRedirected = useRef(false)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [username, setUsername] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
// If already authenticated, redirect to dashboard
if ((walletConnected && walletAddress && user) || (user && authMethod === "email")) {
if (!hasRedirected.current) {
hasRedirected.current = true
router.replace("/dashboard")
}
}
}, [user, walletConnected, walletAddress, authMethod, router])
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim() || !password.trim() || !confirmPassword.trim()) {
toast.error("Please fill in all fields")
return
}
if (password !== confirmPassword) {
toast.error("Passwords do not match")
return
}
if (password.length < 6) {
toast.error("Password must be at least 6 characters")
return
}
setIsSubmitting(true)
try {
const success = await signupWithEmail(email, password, username || email.split("@")[0])
if (success) {
// Auth context handles everything including token storage
// Redirect will be handled by the useEffect
setTimeout(() => {
router.replace("/dashboard")
}, 500)
}
} catch (error: any) {
console.error("Signup error:", error)
toast.error(error.message || "Signup failed")
} finally {
setIsSubmitting(false)
}
}
const handleMetaMaskSignup = async () => {
try {
await connectWallet()
toast.success("Connected with MetaMask!")
setTimeout(() => {
if (walletConnected && walletAddress && user) {
router.replace("/dashboard")
}
}, 500)
} catch (error: any) {
console.error("MetaMask connection failed:", error)
toast.error("MetaMask connection failed")
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
<Card className="w-full max-w-md shadow-2xl">
<CardHeader className="text-center space-y-4">
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
Join OpenLearnX
</CardTitle>
<p className="text-sm text-gray-600">Create your account to start learning</p>
</CardHeader>
<CardContent className="space-y-6">
{/* MetaMask Signup */}
<div className="space-y-4">
<Button
onClick={handleMetaMaskSignup}
disabled={isLoadingAuth || isSubmitting}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white py-3"
>
{isLoadingAuth ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Connecting MetaMask...
</>
) : (
<>
<Wallet className="w-5 h-5 mr-2" />
Sign Up with MetaMask
</>
)}
</Button>
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
<p className="text-xs text-purple-700 dark:text-purple-300 text-center">
Get blockchain features and Web3 verification
</p>
</div>
</div>
<Separator />
{/* Email Signup Form */}
<form onSubmit={handleSignup} className="space-y-4">
<div>
<Label htmlFor="username">Username (optional)</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
disabled={isSubmitting || isLoadingAuth}
/>
</div>
<div>
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
disabled={isSubmitting || isLoadingAuth}
required
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
disabled={isSubmitting || isLoadingAuth}
required
/>
<p className="text-xs text-gray-500 mt-1">Minimum 6 characters</p>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
disabled={isSubmitting || isLoadingAuth}
required
/>
</div>
<Button
type="submit"
disabled={isSubmitting || isLoadingAuth || !email.trim() || !password.trim()}
className="w-full bg-purple-600 hover:bg-purple-700 text-white py-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating Account...
</>
) : (
<>
<Mail className="w-4 h-4 mr-2" />
Sign Up with Email
</>
)}
</Button>
</form>
<div className="text-center pt-4 border-t">
<p className="text-sm text-gray-600">
Already have an account?{" "}
<Link href="/auth/login" className="text-purple-600 hover:text-purple-700 font-semibold">
Sign In
</Link>
</p>
</div>
</CardContent>
</Card>
{/* MetaMask Email Modal */}
{token && walletAddress && (
<MetaMaskEmailModal
isOpen={showMetaMaskEmailModal}
walletAddress={walletAddress}
token={token}
onSuccess={(user) => {
setShowMetaMaskEmailModal(false)
toast.success("Profile setup complete!")
}}
onCancel={() => {
setShowMetaMaskEmailModal(false)
// User can always add email later from dashboard
}}
/>
)}
</div>
)
}
+167 -131
View File
@@ -1,7 +1,7 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Play, Clock, CheckCircle, XCircle, ArrowLeft, Trophy } from 'lucide-react'
import { Play, Clock, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'
interface TestCase {
input: string
@@ -33,8 +33,10 @@ export default function ProblemPage() {
const [testResults, setTestResults] = useState<any[]>([])
const [isRunning, setIsRunning] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showHints, setShowHints] = useState(false)
const [activeTab, setActiveTab] = useState<'description' | 'examples' | 'constraints'>('description')
const [activeTab, setActiveTab] = useState<'description' | 'editorial' | 'solutions' | 'submissions'>('description')
const [detailTab, setDetailTab] = useState<'examples' | 'constraints' | 'hints'>('examples')
const [bottomTab, setBottomTab] = useState<'testcase' | 'result'>('testcase')
const [customInput, setCustomInput] = useState('')
useEffect(() => {
loadProblem(problemId)
@@ -121,6 +123,7 @@ export default function ProblemPage() {
if (selectedProblem) {
setProblem(selectedProblem)
setCode(selectedProblem.starter_code)
setCustomInput(selectedProblem.examples[0]?.input || '')
} else {
// Problem not found
router.push('/coding')
@@ -205,72 +208,72 @@ export default function ProblemPage() {
if (!problem) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-400">Loading problem...</p>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-muted-foreground">Loading problem...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* Header */}
<div className="bg-gray-800 border-b border-gray-700 p-4">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => router.back()}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
const passedCount = testResults.filter((result) => result.passed).length
const allPassed = testResults.length > 0 && passedCount === testResults.length
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/coding')}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-2xl font-bold">{problem.title}</h1>
<div className="flex items-center space-x-3 mt-1">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getDifficultyColor(problem.difficulty)}`}>
<h1 className="text-lg font-semibold">{problem.id}. {problem.title}</h1>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className={`rounded px-2 py-0.5 font-medium ${getDifficultyColor(problem.difficulty)}`}>
{problem.difficulty}
</span>
<span className="text-gray-400 text-sm">{problem.category}</span>
<span>{problem.category}</span>
{allPassed && <span className="text-emerald-600 dark:text-emerald-400">Solved</span>}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-2">
<button
onClick={() => setShowHints(!showHints)}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 rounded-lg text-sm transition-colors"
onClick={runCode}
disabled={isRunning || !code.trim()}
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm text-secondary-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
>
{showHints ? 'Hide Hints' : 'Show Hints'}
{isRunning ? 'Running...' : 'Run'}
</button>
<button
onClick={() => router.push('/coding/exam')}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors flex items-center space-x-2"
onClick={submitSolution}
disabled={isSubmitting || !code.trim()}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trophy className="h-4 w-4" />
<span>Join Exam</span>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Problem Description */}
<div className="space-y-6">
{/* Navigation Tabs */}
<div className="bg-gray-800 rounded-lg">
<div className="flex border-b border-gray-700">
{(['description', 'examples', 'constraints'] as const).map((tab) => (
<main className="h-[calc(100vh-73px)] p-3">
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-2">
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex border-b border-border text-sm">
{(['description', 'editorial', 'solutions', 'submissions'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-6 py-3 font-medium capitalize transition-colors ${
className={`px-4 py-3 capitalize ${
activeTab === tab
? 'bg-gray-700 text-white border-b-2 border-blue-500'
: 'text-gray-400 hover:text-white'
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tab}
@@ -278,164 +281,197 @@ export default function ProblemPage() {
))}
</div>
<div className="p-6">
{activeTab === 'description' && (
<div className="prose prose-invert max-w-none">
<p className="text-gray-300 leading-relaxed">{problem.description}</p>
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-xs">
<button
onClick={() => setDetailTab('examples')}
className={`rounded px-2 py-1 ${detailTab === 'examples' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Examples
</button>
<button
onClick={() => setDetailTab('constraints')}
className={`rounded px-2 py-1 ${detailTab === 'constraints' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Constraints
</button>
<button
onClick={() => setDetailTab('hints')}
className={`rounded px-2 py-1 ${detailTab === 'hints' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Hints
</button>
</div>
)}
{activeTab === 'examples' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Examples:</h3>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{activeTab === 'description' && (
<div className="space-y-4 text-sm text-muted-foreground">
<p className="leading-7">{problem.description}</p>
{detailTab === 'examples' && (
<div className="space-y-3">
{problem.examples.map((example, index) => (
<div key={index} className="bg-gray-900 p-4 rounded-lg">
<div className="mb-2">
<span className="text-blue-400">Input:</span>
<code className="ml-2 text-green-400">"{example.input}"</code>
</div>
<div className="mb-2">
<span className="text-blue-400">Output:</span>
<code className="ml-2 text-green-400">"{example.expected}"</code>
</div>
<div className="text-gray-400 text-sm">{example.description}</div>
<div key={index} className="rounded-lg border border-border bg-secondary/40 p-3">
<p className="font-medium text-foreground">Example {index + 1}</p>
<p className="mt-2"><span className="text-muted-foreground">Input:</span> <code className="text-primary">{example.input}</code></p>
<p><span className="text-muted-foreground">Output:</span> <code className="text-primary">{example.expected}</code></p>
<p className="mt-1 text-xs text-muted-foreground">{example.description}</p>
</div>
))}
</div>
)}
{activeTab === 'constraints' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Constraints:</h3>
<ul className="space-y-2">
{detailTab === 'constraints' && (
<ul className="space-y-2 text-muted-foreground">
{problem.constraints.map((constraint, index) => (
<li key={index} className="flex items-start space-x-2">
<span className="text-blue-400 mt-1"></span>
<span className="text-gray-300">{constraint}</span>
<li key={index} className="rounded border border-border bg-secondary/40 px-3 py-2">
{constraint}
</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Hints Section */}
{showHints && (
<div className="bg-yellow-900 border border-yellow-600 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4 text-yellow-300">💡 Hints:</h3>
{detailTab === 'hints' && (
<ul className="space-y-2">
{problem.hints.map((hint, index) => (
<li key={index} className="flex items-start space-x-2">
<span className="text-yellow-400 mt-1">{index + 1}.</span>
<span className="text-yellow-100">{hint}</span>
<li key={index} className="rounded border border-amber-300 bg-amber-100/70 px-3 py-2 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
{index + 1}. {hint}
</li>
))}
</ul>
)}
</div>
)}
{activeTab === 'editorial' && (
<div className="rounded-lg border border-border bg-secondary/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">Editorial</p>
<p className="mt-2">Approach: Use the Python string method that transforms text to uppercase and return it directly from <code className="text-primary">{problem.function_name}</code>.</p>
</div>
)}
{activeTab === 'solutions' && (
<div className="rounded-lg border border-border bg-secondary/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">Community Solutions</p>
<p className="mt-2">Your submitted solutions will appear here after running Submit.</p>
</div>
)}
{activeTab === 'submissions' && (
<div className="rounded-lg border border-border bg-secondary/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">Submissions</p>
<p className="mt-2">No submissions yet. Run and submit your code to populate this section.</p>
</div>
)}
</div>
</section>
{/* Code Editor & Results */}
<div className="space-y-6">
{/* Code Editor */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">Code Editor</h3>
<span className="text-sm text-gray-400">Python</span>
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-2 text-sm">
<span className="text-foreground">Code</span>
<span className="text-xs text-muted-foreground">Python</span>
</div>
<div className="min-h-0 flex-1 border-b border-border">
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full h-80 bg-gray-900 text-green-400 font-mono p-4 rounded border border-gray-600 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="h-full w-full resize-none bg-background p-4 font-mono text-sm text-foreground outline-none"
spellCheck={false}
/>
<div className="flex justify-between items-center mt-4">
<div className="text-sm text-gray-400">
Function: <code className="text-blue-400">{problem.function_name}</code>
</div>
<div className="flex space-x-3">
<div className="h-[38%] min-h-[220px]">
<div className="flex border-b border-border text-sm">
<button
onClick={() => setBottomTab('testcase')}
className={`px-4 py-2 ${bottomTab === 'testcase' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Testcase
</button>
<button
onClick={() => setBottomTab('result')}
className={`px-4 py-2 ${bottomTab === 'result' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Test Result
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{bottomTab === 'testcase' && (
<div className="space-y-3">
<label className="text-xs font-medium text-muted-foreground">Custom Input</label>
<textarea
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
className="h-24 w-full rounded border border-border bg-secondary/40 p-3 font-mono text-sm text-foreground outline-none"
placeholder="Enter custom testcase input"
/>
<p className="text-xs text-muted-foreground">Function: <code className="text-primary">{problem.function_name}</code></p>
<div className="flex gap-2">
<button
onClick={runCode}
disabled={isRunning || !code.trim()}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
className="inline-flex items-center gap-2 rounded bg-secondary px-3 py-2 text-sm text-secondary-foreground hover:bg-accent disabled:opacity-60"
>
<Play className="h-4 w-4" />
<span>{isRunning ? 'Running...' : 'Run Code'}</span>
{isRunning ? 'Running...' : 'Run'}
</button>
<button
onClick={submitSolution}
disabled={isSubmitting || !code.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
className="inline-flex items-center gap-2 rounded bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
>
<CheckCircle className="h-4 w-4" />
<span>{isSubmitting ? 'Submitting...' : 'Submit'}</span>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>
)}
{/* Output & Test Results */}
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-bold mb-4">Output & Test Results</h3>
{/* Console Output */}
{bottomTab === 'result' && (
<div className="space-y-3">
{output && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-400 mb-2">Console Output:</h4>
<div className="bg-black p-4 rounded font-mono text-sm">
<pre className="text-green-400 whitespace-pre-wrap">{output}</pre>
</div>
<div className="rounded border border-border bg-secondary/40 p-3">
<p className="mb-2 text-xs text-muted-foreground">Console</p>
<pre className="whitespace-pre-wrap text-sm text-foreground">{output}</pre>
</div>
)}
{/* Test Results */}
{testResults.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-400 mb-2">Test Results:</h4>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Passed {passedCount}/{testResults.length} tests</p>
{testResults.map((result, index) => (
<div
key={index}
className={`p-3 rounded flex items-center justify-between ${
result.passed ? 'bg-green-900 border border-green-600' : 'bg-red-900 border border-red-600'
className={`flex items-center justify-between rounded border px-3 py-2 text-sm ${
result.passed
? 'border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-950/40 dark:text-red-300'
}`}
>
<div className="flex items-center space-x-2">
{result.passed ? (
<CheckCircle className="h-4 w-4 text-green-400" />
) : (
<XCircle className="h-4 w-4 text-red-400" />
)}
<span className="text-sm">Test {index + 1}</span>
</div>
<div className="text-right text-sm">
{result.passed ? (
<span className="text-green-400">Passed</span>
) : (
<span className="text-red-400">Failed: {result.error}</span>
)}
</div>
<span className="flex items-center gap-2">
{result.passed ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
Test {index + 1}
</span>
<span>{result.passed ? 'Passed' : `Failed${result.error ? `: ${result.error}` : ''}`}</span>
</div>
))}
</div>
</div>
)}
{!output && testResults.length === 0 && (
<div className="text-center text-gray-400 py-8">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Run your code to see output and test results</p>
<div className="py-8 text-center text-muted-foreground">
<Clock className="mx-auto mb-2 h-8 w-8 opacity-60" />
Run your code to see results.
</div>
)}
</div>
)}
</div>
</div>
</section>
</div>
</main>
</div>
)
}
+146 -215
View File
@@ -1,7 +1,7 @@
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Trophy, Clock, Users, Send, RefreshCw, Play, Code, Wallet, Shield, TestTube } from 'lucide-react'
import { Trophy, Clock, Users, Send, RefreshCw, Play, Code, Shield, TestTube } from 'lucide-react'
interface Participant {
name: string
@@ -54,6 +54,8 @@ export default function EnhancedExamInterface() {
const [hasSubmitted, setHasSubmitted] = useState(false)
const [examStats, setExamStats] = useState<any>({})
const [timerInitialized, setTimerInitialized] = useState(false)
const [leftTab, setLeftTab] = useState<'description' | 'examples' | 'constraints'>('description')
const [rightTab, setRightTab] = useState<'result' | 'leaderboard'>('result')
// ✅ CRITICAL FIX: Use refs to prevent infinite loops
const intervalRef = useRef<NodeJS.Timeout | null>(null)
@@ -62,11 +64,11 @@ export default function EnhancedExamInterface() {
const isInitializedRef = useRef(false)
const languageIcons: {[key: string]: string} = {
python: '🐍',
java: '',
javascript: '🟨',
c: '',
bash: '💻'
python: 'Py',
java: 'Java',
javascript: 'JS',
c: 'C',
bash: 'Sh'
}
// ✅ FIXED: Memoized functions to prevent recreation
@@ -199,7 +201,7 @@ export default function EnhancedExamInterface() {
setTimeRemaining(prev => {
const newTime = Math.max(0, prev - 1)
if (newTime === 0) {
alert('Time is up! Exam has ended.')
alert('Time is up. Exam has ended.')
}
return newTime
})
@@ -255,12 +257,12 @@ export default function EnhancedExamInterface() {
const result = await response.json()
if (result.success) {
setOutput(`Output:\n${result.output}`)
setOutput(`Output:\n${result.output}`)
if (result.execution_time) {
setOutput(prev => prev + `\n⏱️ Execution time: ${result.execution_time}s`)
setOutput(prev => prev + `\nExecution time: ${result.execution_time}s`)
}
} else {
setOutput(`Error:\n${result.error}`)
setOutput(`Error:\n${result.error}`)
}
} catch (error) {
setOutput(`Execution failed: ${(error as Error).message}`)
@@ -313,15 +315,15 @@ export default function EnhancedExamInterface() {
setHasSubmitted(true)
setTestResults(data.result?.test_results || [])
let alertMessage = `🎉 Solution submitted successfully!\n\n`
alertMessage += `📊 Overall Score: ${data.result?.score || 0}%\n`
alertMessage += `Tests Passed: ${data.result?.passed_tests || 0}/${data.result?.total_tests || 1}\n`
let alertMessage = `Solution submitted successfully.\n\n`
alertMessage += `Overall Score: ${data.result?.score || 0}%\n`
alertMessage += `Tests Passed: ${data.result?.passed_tests || 0}/${data.result?.total_tests || 1}\n`
if (data.result?.execution_time) {
alertMessage += `⏱️ Execution Time: ${data.result.execution_time}s\n`
alertMessage += `Execution Time: ${data.result.execution_time}s\n`
}
alertMessage += `\n🏆 Check the leaderboard for your ranking!`
alertMessage += `\nCheck the leaderboard for your ranking.`
alert(alertMessage)
// ✅ FIXED: Controlled refresh sequence - clear previous timeouts
@@ -342,12 +344,12 @@ export default function EnhancedExamInterface() {
refreshTimeoutRefs.current.push(refreshTimeout)
} else {
alert(`Submission failed: ${data.error}`)
alert(`Submission failed: ${data.error}`)
}
} catch (error) {
console.error('Submit network error:', error)
alert('Network error: Could not submit solution. Please try again.')
console.error('Submit network error:', error)
alert('Network error: Could not submit solution. Please try again.')
} finally {
setIsSubmitting(false)
}
@@ -364,9 +366,9 @@ export default function EnhancedExamInterface() {
if (!results || results.length === 0) return null
return (
<div className="mt-6 bg-gray-900 p-4 rounded border border-gray-600">
<h4 className="text-lg font-semibold text-white mb-4 flex items-center space-x-2">
<TestTube className="h-5 w-5 text-blue-400" />
<div className="mt-6 rounded border border-border bg-secondary/40 p-4">
<h4 className="mb-4 flex items-center space-x-2 text-lg font-semibold text-foreground">
<TestTube className="h-5 w-5 text-primary" />
<span>Test Results</span>
</h4>
@@ -383,9 +385,9 @@ export default function EnhancedExamInterface() {
<div className="flex justify-between items-start mb-2">
<div className="flex items-center space-x-2">
<span className="font-semibold">
Test {index + 1}: {result.passed ? 'PASSED' : 'FAILED'}
Test {index + 1}: {result.passed ? 'PASSED' : 'FAILED'}
</span>
<span className="text-sm bg-black bg-opacity-30 px-2 py-1 rounded font-bold">
<span className="rounded bg-secondary px-2 py-1 text-sm font-bold text-secondary-foreground">
+{result.points_earned || 0} points
</span>
</div>
@@ -399,7 +401,7 @@ export default function EnhancedExamInterface() {
{result.input && (
<div>
<span className="font-medium">Input:</span>
<code className="ml-2 bg-black bg-opacity-30 px-2 py-1 rounded">
<code className="ml-2 rounded bg-secondary px-2 py-1 text-secondary-foreground">
"{result.input}"
</code>
</div>
@@ -408,7 +410,7 @@ export default function EnhancedExamInterface() {
{result.expected_output && (
<div>
<span className="font-medium">Expected:</span>
<code className="ml-2 bg-black bg-opacity-30 px-2 py-1 rounded">
<code className="ml-2 rounded bg-secondary px-2 py-1 text-secondary-foreground">
"{result.expected_output}"
</code>
</div>
@@ -417,7 +419,7 @@ export default function EnhancedExamInterface() {
{result.actual_output && (
<div>
<span className="font-medium">Your Output:</span>
<code className="ml-2 bg-black bg-opacity-30 px-2 py-1 rounded">
<code className="ml-2 rounded bg-secondary px-2 py-1 text-secondary-foreground">
"{result.actual_output}"
</code>
</div>
@@ -425,7 +427,7 @@ export default function EnhancedExamInterface() {
</div>
{!result.passed && result.error && (
<div className="mt-2 p-2 bg-red-800 bg-opacity-50 rounded text-sm">
<div className="mt-2 rounded bg-red-100 p-2 text-sm text-red-800 dark:bg-red-900/40 dark:text-red-200">
<span className="font-medium">Error:</span> {result.error}
</div>
)}
@@ -455,270 +457,199 @@ export default function EnhancedExamInterface() {
if (!examSession || !problem) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-400">Loading exam interface...</p>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-muted-foreground">Loading exam interface...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* Header with Timer */}
<div className="bg-gray-800 border-b border-gray-700 p-4">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border bg-card px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">{problem.title}</h1>
<p className="text-gray-400">Code: {examCode} | Participant: {examSession.student_name}</p>
<h1 className="text-lg font-semibold">{problem.title}</h1>
<p className="text-xs text-muted-foreground">Code: {examCode} | Participant: {examSession.student_name}</p>
</div>
<div className="flex items-center space-x-4">
{/* Timer */}
<div className="flex items-center gap-3">
{timeRemaining > 0 && (
<div className={`flex items-center space-x-2 px-3 py-1 rounded-lg ${
timeRemaining <= 300 ? 'bg-red-900' : timeRemaining <= 600 ? 'bg-yellow-900' : 'bg-green-900'
<div className={`rounded-md px-3 py-1 text-sm font-mono ${
timeRemaining <= 300 ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : timeRemaining <= 600 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
}`}>
<Clock className={`h-5 w-5 ${
timeRemaining <= 300 ? 'text-red-400' : timeRemaining <= 600 ? 'text-yellow-400' : 'text-green-400'
}`} />
<span className={`font-mono text-lg ${
timeRemaining <= 300 ? 'text-red-400' : timeRemaining <= 600 ? 'text-yellow-400' : 'text-green-400'
}`}>
{formatTime(timeRemaining)}
</span>
<span className="inline-flex items-center gap-1"><Clock className="h-4 w-4" /> {formatTime(timeRemaining)}</span>
</div>
)}
{/* Participant Count */}
<div className="flex items-center space-x-2">
<Users className="h-5 w-5 text-blue-400" />
<span>{examStats.total_participants || 0} participants</span>
<div className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-sm text-muted-foreground">
<Users className="h-4 w-4" /> {examStats.total_participants || 0}
</div>
{/* Submission Status Indicator */}
{hasSubmitted && (
<div className="flex items-center space-x-2 bg-green-900 px-3 py-1 rounded-lg">
<Shield className="h-4 w-4 text-green-400" />
<span className="text-green-200 text-sm"> Submitted</span>
<div className="inline-flex items-center gap-1 rounded-md bg-emerald-100 px-2 py-1 text-sm text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
<Shield className="h-4 w-4" /> Submitted
</div>
)}
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Problem & Code Editor */}
<div className="lg:col-span-2 space-y-6">
{/* Problem Description */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">{problem.title}</h2>
{hasSubmitted && (
<div className="flex items-center space-x-1 text-green-400 text-sm">
<Shield className="h-4 w-4" />
<span>Solution Submitted</span>
<main className="h-[calc(100vh-73px)] p-3">
<div className="grid h-full grid-cols-1 gap-3 xl:grid-cols-5">
<section className="xl:col-span-2 flex min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex border-b border-border text-sm">
<button onClick={() => setLeftTab('description')} className={`px-4 py-2 ${leftTab === 'description' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Description</button>
<button onClick={() => setLeftTab('examples')} className={`px-4 py-2 ${leftTab === 'examples' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Examples</button>
<button onClick={() => setLeftTab('constraints')} className={`px-4 py-2 ${leftTab === 'constraints' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Constraints</button>
</div>
)}
</div>
<div className="prose prose-invert">
<p className="mb-4 text-gray-300">{problem.description}</p>
<h4 className="text-lg font-semibold mb-2">Examples:</h4>
<div className="min-h-0 flex-1 overflow-y-auto p-4 text-sm text-muted-foreground">
{leftTab === 'description' && <p className="leading-7">{problem.description}</p>}
{leftTab === 'examples' && (
<div className="space-y-3">
{problem.examples.map((example, index) => (
<div key={index} className="bg-gray-900 p-4 rounded mb-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-blue-400">Input:</span>
<code className="ml-2 text-green-400">"{example.input}"</code>
</div>
<div>
<span className="text-blue-400">Output:</span>
<code className="ml-2 text-green-400">"{example.expected_output}"</code>
</div>
</div>
{example.description && (
<div className="mt-2 text-gray-400 text-sm">{example.description}</div>
)}
<div key={index} className="rounded-lg border border-border bg-secondary/40 p-3">
<p className="font-medium text-foreground">Example {index + 1}</p>
<p className="mt-1"><span className="text-muted-foreground">Input:</span> <code className="text-primary">{example.input}</code></p>
<p><span className="text-muted-foreground">Output:</span> <code className="text-primary">{example.expected_output}</code></p>
{example.description ? <p className="mt-1 text-xs text-muted-foreground">{example.description}</p> : null}
</div>
))}
<h4 className="text-lg font-semibold mb-2">Constraints:</h4>
<ul className="list-disc list-inside mb-4 text-gray-300">
</div>
)}
{leftTab === 'constraints' && (
<ul className="space-y-2">
{problem.constraints.map((constraint, index) => (
<li key={index}>{constraint}</li>
<li key={index} className="rounded border border-border bg-secondary/40 px-3 py-2">{constraint}</li>
))}
</ul>
)}
</div>
</div>
</section>
{/* Code Editor */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">Your Solution</h3>
{/* Language Selector */}
<div className="flex items-center space-x-2">
<Code className="h-4 w-4 text-gray-400" />
<section className="xl:col-span-3 flex min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2">
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Code className="h-4 w-4" />
<select
value={selectedLanguage}
onChange={(e) => handleLanguageChange(e.target.value)}
disabled={hasSubmitted}
className="bg-gray-700 text-white px-3 py-1 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500"
className="rounded border border-border bg-secondary px-2 py-1 text-sm text-secondary-foreground"
>
{problem.languages.map(lang => (
<option key={lang} value={lang}>
{languageIcons[lang]} {lang.charAt(0).toUpperCase() + lang.slice(1)}
</option>
<option key={lang} value={lang}>{languageIcons[lang]} {lang.charAt(0).toUpperCase() + lang.slice(1)}</option>
))}
</select>
</div>
<span className="text-xs text-muted-foreground">Function: {problem.function_name}</span>
</div>
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full h-64 bg-gray-900 text-green-400 font-mono p-4 rounded border border-gray-600 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={hasSubmitted}
spellCheck={false}
placeholder={hasSubmitted ? 'Solution submitted!' : `Write your ${selectedLanguage} solution here...`}
/>
<div className="flex justify-between items-center mt-4">
<div className="text-sm text-gray-400">
Function: <code className="text-blue-400">{problem.function_name}</code>
{hasSubmitted && (
<span className="ml-4 text-green-400">
Solution submitted successfully!
</span>
)}
</div>
<div className="flex space-x-3">
<div className="flex items-center gap-2">
<button
onClick={runCode}
disabled={isRunning || hasSubmitted || !code.trim()}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
className="inline-flex items-center gap-1 rounded bg-secondary px-3 py-1.5 text-sm text-secondary-foreground hover:bg-accent disabled:opacity-60"
>
<Play className="h-4 w-4" />
<span>{isRunning ? 'Running...' : 'Test Code'}</span>
<Play className="h-4 w-4" /> {isRunning ? 'Running...' : 'Run'}
</button>
<button
onClick={submitSolution}
disabled={isSubmitting || hasSubmitted || !code.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
className="inline-flex items-center gap-1 rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
>
<Send className="h-4 w-4" />
<span>{isSubmitting ? 'Submitting...' : hasSubmitted ? 'Submitted ✅' : 'Submit Solution'}</span>
<Send className="h-4 w-4" /> {isSubmitting ? 'Submitting...' : hasSubmitted ? 'Submitted' : 'Submit'}
</button>
</div>
</div>
{/* Output Display */}
{output && (
<div className="mt-6 bg-gray-900 p-4 rounded">
<h4 className="text-sm font-medium text-gray-400 mb-2">Output:</h4>
<pre className="text-green-400 text-sm whitespace-pre-wrap">{output}</pre>
</div>
)}
<div className="min-h-0 flex-1 border-b border-border">
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-full w-full resize-none bg-background p-4 font-mono text-sm text-foreground outline-none"
disabled={hasSubmitted}
spellCheck={false}
placeholder={hasSubmitted ? 'Solution submitted.' : `Write your ${selectedLanguage} solution here...`}
/>
</div>
{/* Test Results Display */}
{testResults.length > 0 && (
<TestResultsDisplay results={testResults} />
)}
<div className="h-[40%] min-h-[240px]">
<div className="flex items-center justify-between border-b border-border px-2">
<div className="flex text-sm">
<button onClick={() => setRightTab('result')} className={`px-3 py-2 ${rightTab === 'result' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Test Result</button>
<button onClick={() => setRightTab('leaderboard')} className={`px-3 py-2 ${rightTab === 'leaderboard' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Leaderboard</button>
</div>
{/* Enhanced Leaderboard */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Trophy className="h-6 w-6 text-yellow-400" />
<h3 className="text-xl font-bold">Live Leaderboard</h3>
</div>
<button
onClick={manualRefresh}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded"
title="Refresh"
>
{rightTab === 'leaderboard' && (
<button onClick={manualRefresh} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground" title="Refresh">
<RefreshCw className="h-4 w-4" />
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-gray-900 p-3 rounded">
<div className="text-2xl font-bold text-blue-400">{examStats.completed_submissions || 0}</div>
<div className="text-xs text-gray-400">Submitted</div>
</div>
<div className="bg-gray-900 p-3 rounded">
<div className="text-2xl font-bold text-green-400">{Math.round(examStats.average_score || 0)}%</div>
<div className="text-xs text-gray-400">Avg Score</div>
</div>
</div>
{/* Leaderboard Display */}
<div className="space-y-2">
<h4 className="font-semibold text-gray-300 mb-3">🏆 Rankings</h4>
{leaderboard.length > 0 ? (
leaderboard.map((participant) => (
<div key={participant.name} className={`p-3 rounded-lg ${getRankColor(participant.rank)}`}>
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<span className="font-bold text-lg">#{participant.rank}</span>
<div>
<div className={`font-medium ${participant.name === examSession.student_name ? 'underline font-bold' : ''}`}>
{participant.name}
{participant.name === examSession.student_name && ' (You) 🎯'}
</div>
<div className="text-xs opacity-75 flex items-center space-x-2">
{participant.language && (
<span>
{languageIcons[participant.language]} {participant.language}
</span>
)}
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{rightTab === 'result' && (
<div className="space-y-3">
{output ? (
<div className="rounded border border-border bg-secondary/40 p-3">
<pre className="whitespace-pre-wrap text-sm text-foreground">{output}</pre>
</div>
</div>
<div className="text-right">
<span className="font-bold text-lg">{participant.score}%</span>
<div className="text-xs opacity-75">
Submitted
</div>
</div>
</div>
</div>
))
) : (
<div className="text-center text-gray-400 py-4">
No submissions yet
<p className="text-sm text-muted-foreground">Run your code to see output.</p>
)}
{testResults.length > 0 ? <TestResultsDisplay results={testResults} /> : null}
</div>
)}
{rightTab === 'leaderboard' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded border border-border bg-secondary/40 p-3">
<p className="text-xl font-bold text-primary">{examStats.completed_submissions || 0}</p>
<p className="text-xs text-muted-foreground">Submitted</p>
</div>
<div className="rounded border border-border bg-secondary/40 p-3">
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{Math.round(examStats.average_score || 0)}%</p>
<p className="text-xs text-muted-foreground">Average Score</p>
</div>
</div>
<div className="space-y-2">
<h4 className="inline-flex items-center gap-2 text-sm font-semibold text-foreground"><Trophy className="h-4 w-4 text-yellow-500" /> Rankings</h4>
{leaderboard.length > 0 ? leaderboard.map((participant) => (
<div key={participant.name} className={`rounded p-3 ${getRankColor(participant.rank)}`}>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${participant.name === examSession.student_name ? 'underline' : ''}`}>
#{participant.rank} {participant.name}{participant.name === examSession.student_name ? ' (You)' : ''}
</p>
<p className="text-xs opacity-80">{participant.language || 'language'} submitted</p>
</div>
<p className="font-semibold">{participant.score}%</p>
</div>
</div>
)) : <p className="text-sm text-muted-foreground">No submissions yet.</p>}
</div>
{/* Waiting Participants */}
{waitingParticipants.length > 0 && (
<div className="mt-6">
<h4 className="font-semibold text-gray-300 mb-3"> Still Working</h4>
<div>
<h4 className="mb-2 text-sm font-semibold text-foreground">Still Working</h4>
<div className="space-y-1">
{waitingParticipants.map((participant) => (
<div key={participant.name} className="p-2 bg-gray-700 rounded text-sm flex items-center justify-between">
<span>
{participant.name}
{participant.name === examSession.student_name && ' (You)'}
</span>
<span className="text-yellow-400 text-xs">Working...</span>
<div key={participant.name} className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm text-secondary-foreground">
<span>{participant.name}{participant.name === examSession.student_name ? ' (You)' : ''}</span>
<span className="text-xs text-amber-600 dark:text-amber-300">Working...</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</section>
</div>
</main>
</div>
)
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function ExamLandingPage() {
onChange={(e) => setExamCode(e.target.value.toUpperCase())}
onKeyPress={handleKeyPress}
placeholder="Enter exam code (e.g. ABC123)"
className="flex-1 p-4 bg-gray-700 border border-gray-600 rounded-lg text-center text-xl font-mono tracking-widest"
className="flex-1 p-4 bg-gray-700 border border-gray-600 rounded-lg text-center text-xl font-mono tracking-widest text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={6}
/>
<button
+9 -2
View File
@@ -27,10 +27,16 @@ export default function JoinExam() {
setLoading(true)
try {
const token = localStorage.getItem("openlearnx_jwt_token")
const storedUserRaw = localStorage.getItem("openlearnx_user")
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
// ✅ CORRECT FIELD NAMES - Must match backend expectations
const payload = {
exam_code: examCode.trim().toUpperCase(), // Backend expects exam_code
student_name: studentName.trim() // Backend expects student_name
student_name: studentName.trim(), // Backend expects student_name
wallet_address: storedUser?.wallet_address,
user_id: storedUser?.id
}
console.log('🚀 Sending payload:', payload)
@@ -39,7 +45,8 @@ export default function JoinExam() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
'Accept': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(payload) // ✅ MUST stringify the payload
})
+48 -48
View File
@@ -289,7 +289,7 @@ Redirecting to exam interface...`)
// Role Selection Screen with Enhanced Animations
if (userRole === 'selector') {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center relative overflow-hidden animate-fade-in">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#24467d] dark:to-[#4a2f86] flex items-center justify-center relative overflow-hidden animate-fade-in">
{/* Animated Background Elements */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-white rounded-full animate-float"></div>
@@ -310,7 +310,7 @@ Redirecting to exam interface...`)
<Star className="w-4 h-4 text-white opacity-50 animate-spin-slow" />
</div>
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-2xl p-10 max-w-lg w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group">
<div className="bg-white/95 dark:bg-[#22314a]/95 backdrop-blur-sm rounded-2xl shadow-2xl p-10 max-w-lg w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group border border-gray-200 dark:border-blue-300/20">
{/* Card shine effect */}
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000"></div>
@@ -319,10 +319,10 @@ Redirecting to exam interface...`)
<div className="flex justify-center mb-4 animate-bounce">
<Code className="h-16 w-16 text-blue-600 animate-pulse" />
</div>
<h1 className="text-3xl font-bold text-gray-800 mb-3 animate-slide-down">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mb-3 animate-slide-down">
OpenLearnX Coding Exam
</h1>
<p className="text-gray-600 animate-fade-in animate-delay-300">
<p className="text-gray-600 dark:text-gray-300 animate-fade-in animate-delay-300">
Choose your role to get started
</p>
</div>
@@ -330,7 +330,7 @@ Redirecting to exam interface...`)
<div className="space-y-6">
<button
onClick={() => setUserRole('host')}
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 dark:from-blue-700 dark:to-blue-800 hover:from-blue-700 hover:to-blue-800 dark:hover:from-blue-800 dark:hover:to-blue-900 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
style={{ animationDelay: '0.1s' }}
>
{/* Button background animation */}
@@ -349,7 +349,7 @@ Redirecting to exam interface...`)
<button
onClick={() => setUserRole('participant')}
className="w-full bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
className="w-full bg-gradient-to-r from-green-600 to-green-700 dark:from-green-700 dark:to-green-800 hover:from-green-700 hover:to-green-800 dark:hover:from-green-800 dark:hover:to-green-900 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
style={{ animationDelay: '0.2s' }}
>
{/* Button background animation */}
@@ -369,7 +369,7 @@ Redirecting to exam interface...`)
{/* Animated footer */}
<div className="mt-8 text-center animate-fade-in animate-delay-500">
<p className="text-sm text-gray-500 hover:text-gray-700 transition-colors duration-300">
<p className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-300">
Secure Real-time Professional
</p>
</div>
@@ -382,7 +382,7 @@ Redirecting to exam interface...`)
// Host Setup Screen with Enhanced UI
if (userRole === 'host' && !examId) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-indigo-900 to-purple-900 flex items-center justify-center relative overflow-hidden animate-fade-in">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#24467d] dark:to-[#4a2f86] flex items-center justify-center relative overflow-hidden animate-fade-in">
{/* Enhanced background animations */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-0 left-0 w-96 h-96 bg-white rounded-full mix-blend-overlay animate-blob"></div>
@@ -398,7 +398,7 @@ Redirecting to exam interface...`)
<Zap className="w-6 h-6 text-white opacity-20 animate-bounce" />
</div>
<div className="bg-white/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group">
<div className="bg-white/95 dark:bg-[#22314a]/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group border border-gray-200 dark:border-blue-300/20">
{/* Enhanced shine effect */}
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-blue-200/30 to-transparent transition-transform duration-1000"></div>
@@ -412,10 +412,10 @@ Redirecting to exam interface...`)
<div className="absolute -top-2 -right-2 w-3 h-3 bg-blue-400 rounded-full animate-ping"></div>
<div className="absolute -bottom-2 -left-2 w-2 h-2 bg-blue-300 rounded-full animate-ping animation-delay-500"></div>
</div>
<h1 className="text-4xl font-bold text-gray-800 mb-4 animate-slide-down">
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-4 animate-slide-down">
Host Coding Exam
</h1>
<p className="text-gray-600 text-lg animate-fade-in animate-delay-300">
<p className="text-gray-600 dark:text-gray-300 text-lg animate-fade-in animate-delay-300">
Create a secure coding environment for your participants
</p>
</div>
@@ -427,7 +427,7 @@ Redirecting to exam interface...`)
placeholder="Enter your name"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
className="w-full p-4 border-2 border-gray-200 rounded-xl text-lg transition-all duration-300 focus:ring-4 focus:ring-blue-200 focus:border-blue-500 hover:border-blue-300 bg-gray-50 hover:bg-white focus:bg-white group"
className="w-full p-4 border-2 border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 rounded-xl text-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-300 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-800 focus:border-blue-500 dark:focus:border-blue-400 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-white dark:hover:bg-gray-600 focus:bg-white dark:focus:bg-gray-700 group"
/>
{/* Input decoration */}
<div className="absolute right-4 top-1/2 transform -translate-y-1/2 opacity-0 group-focus-within:opacity-100 transition-opacity duration-300">
@@ -438,7 +438,7 @@ Redirecting to exam interface...`)
<button
onClick={createExam}
disabled={!participantName}
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 disabled:from-gray-400 disabled:to-gray-500 text-white py-4 px-6 rounded-xl text-lg font-semibold transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 disabled:hover:scale-100 animate-slide-up group relative overflow-hidden"
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-700 dark:to-indigo-700 hover:from-blue-700 hover:to-indigo-700 dark:hover:from-blue-800 dark:hover:to-indigo-800 disabled:from-gray-400 disabled:to-gray-500 text-white py-4 px-6 rounded-xl text-lg font-semibold transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 disabled:hover:scale-100 animate-slide-up group relative overflow-hidden"
style={{ animationDelay: '0.2s' }}
>
{/* Button animation background */}
@@ -455,7 +455,7 @@ Redirecting to exam interface...`)
</div>
{/* Enhanced Debug Info */}
<div className="mt-8 p-6 bg-gradient-to-r from-gray-50 to-blue-50 rounded-xl text-sm text-gray-600 animate-fade-in border border-gray-200 hover:border-blue-300 transition-colors duration-300" style={{ animationDelay: '0.3s' }}>
<div className="mt-8 p-6 bg-gradient-to-r from-gray-50 to-blue-50 dark:from-gray-700 dark:to-gray-800 rounded-xl text-sm text-gray-600 dark:text-gray-300 animate-fade-in border border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 transition-colors duration-300" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center space-x-2 mb-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="font-semibold">System Status</span>
@@ -484,7 +484,7 @@ Redirecting to exam interface...`)
// Join Exam Screen with Enhanced Animations
if (userRole === 'participant' && !examInfo) {
return (
<div className="min-h-screen bg-gradient-to-br from-green-900 via-emerald-900 to-blue-900 flex items-center justify-center relative overflow-hidden animate-fade-in">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-green-50 to-blue-50 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#1f4f63] dark:to-[#274f80] flex items-center justify-center relative overflow-hidden animate-fade-in">
{/* Enhanced background effects */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-1/4 left-1/4 w-40 h-40 bg-white rounded-full animate-float hover:scale-150 transition-transform duration-500"></div>
@@ -499,7 +499,7 @@ Redirecting to exam interface...`)
<div className="absolute top-1/2 left-1/5 w-2.5 h-2.5 bg-white rounded-full animate-pulse animate-delay-700 opacity-50"></div>
</div>
<div className="bg-white/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group">
<div className="bg-white/95 dark:bg-[#22314a]/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group border border-gray-200 dark:border-blue-300/20">
{/* Enhanced card effects */}
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-green-200/30 to-transparent transition-transform duration-1000"></div>
@@ -513,10 +513,10 @@ Redirecting to exam interface...`)
<div className="absolute inset-0 border-4 border-green-300 rounded-full animate-ping opacity-30"></div>
<div className="absolute inset-2 border-2 border-green-400 rounded-full animate-ping opacity-40 animation-delay-500"></div>
</div>
<h1 className="text-4xl font-bold text-gray-800 mb-4 animate-slide-down">
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-4 animate-slide-down">
Join Coding Exam
</h1>
<p className="text-gray-600 text-lg animate-fade-in animate-delay-300">
<p className="text-gray-600 dark:text-gray-300 text-lg animate-fade-in animate-delay-300">
Enter the exam code to participate in the coding challenge
</p>
</div>
@@ -528,7 +528,7 @@ Redirecting to exam interface...`)
placeholder="Enter exam code (e.g., 3BPIBZ)"
value={examId}
onChange={(e) => setExamId(e.target.value.toUpperCase())}
className="w-full p-4 border-2 border-gray-200 rounded-xl text-center font-mono text-2xl tracking-widest uppercase transition-all duration-300 focus:ring-4 focus:ring-green-200 focus:border-green-500 hover:border-green-300 bg-gray-50 hover:bg-white focus:bg-white relative group"
className="w-full p-4 border-2 border-gray-200 dark:border-gray-600 rounded-xl text-center font-mono text-2xl tracking-widest uppercase text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-300 focus:ring-4 focus:ring-green-200 dark:focus:ring-green-800 focus:border-green-500 dark:focus:border-green-400 hover:border-green-300 dark:hover:border-green-500 bg-gray-50 dark:bg-gray-700 hover:bg-white dark:hover:bg-gray-600 focus:bg-white dark:focus:bg-gray-700 relative group"
maxLength={6}
/>
{/* Input decorations */}
@@ -546,7 +546,7 @@ Redirecting to exam interface...`)
placeholder="Enter your name"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
className="w-full p-4 border-2 border-gray-200 rounded-xl text-lg transition-all duration-300 focus:ring-4 focus:ring-green-200 focus:border-green-500 hover:border-green-300 bg-gray-50 hover:bg-white focus:bg-white group"
className="w-full p-4 border-2 border-gray-200 dark:border-gray-600 rounded-xl text-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-300 focus:ring-4 focus:ring-green-200 dark:focus:ring-green-800 focus:border-green-500 dark:focus:border-green-400 hover:border-green-300 dark:hover:border-green-500 bg-gray-50 dark:bg-gray-700 hover:bg-white dark:hover:bg-gray-600 focus:bg-white dark:focus:bg-gray-700 group"
/>
{/* Name validation indicator */}
{participantName.length > 2 && (
@@ -579,7 +579,7 @@ Redirecting to exam interface...`)
</button>
{/* Enhanced Debug Info */}
<div className="text-sm text-gray-500 p-6 bg-gradient-to-r from-gray-50 to-green-50 rounded-xl animate-fade-in border border-gray-200 hover:border-green-300 transition-colors duration-300" style={{ animationDelay: '0.4s' }}>
<div className="text-sm text-gray-500 dark:text-gray-400 p-6 bg-gradient-to-r from-gray-50 to-green-50 dark:from-gray-700 dark:to-green-800 rounded-xl animate-fade-in border border-gray-200 dark:border-gray-600 hover:border-green-300 dark:hover:border-green-500 transition-colors duration-300" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center space-x-2 mb-3">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
<span className="font-semibold">Connection Status</span>
@@ -605,7 +605,7 @@ Redirecting to exam interface...`)
// Enhanced System Requirements Check
if (!systemChecked) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-red-900 to-black text-white flex items-center justify-center relative overflow-hidden animate-fade-in">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#3f3b77] dark:to-[#4a2f86] text-gray-900 dark:text-white flex items-center justify-center relative overflow-hidden animate-fade-in">
{/* Animated warning elements */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-red-500 rounded-full animate-pulse"></div>
@@ -621,7 +621,7 @@ Redirecting to exam interface...`)
<Shield className="w-6 h-6 text-yellow-400 opacity-40 animate-bounce" />
</div>
<div className="bg-gray-800/95 backdrop-blur-lg rounded-3xl p-12 max-w-2xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 border border-red-500/30 relative overflow-hidden group">
<div className="bg-white dark:bg-[#22314a]/95 backdrop-blur-lg rounded-3xl p-12 max-w-2xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 border border-red-500/30 dark:border-red-400/30 relative overflow-hidden group">
{/* Security-themed background */}
<div className="absolute inset-0 bg-gradient-to-r from-red-900/20 to-yellow-900/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
@@ -638,7 +638,7 @@ Redirecting to exam interface...`)
<h1 className="text-4xl font-bold mb-6 animate-slide-down">
System Requirements Check
</h1>
<p className="text-xl text-gray-300 animate-fade-in animate-delay-300">
<p className="text-xl text-gray-300 dark:text-gray-300 animate-fade-in animate-delay-300">
Preparing secure exam environment
</p>
</div>
@@ -648,7 +648,7 @@ Redirecting to exam interface...`)
<Shield className="h-8 w-8 text-green-400 animate-pulse" />
<div className="flex-1">
<span className="text-lg font-medium">Fullscreen mode support</span>
<p className="text-sm text-gray-400">Required for secure examination</p>
<p className="text-sm text-gray-400 dark:text-gray-400">Required for secure examination</p>
</div>
<CheckCircle className="h-6 w-6 text-green-400 animate-bounce" />
</div>
@@ -657,7 +657,7 @@ Redirecting to exam interface...`)
<Lock className="h-8 w-8 text-yellow-400 animate-bounce" />
<div className="flex-1">
<span className="text-lg font-medium">Copy/paste will be disabled</span>
<p className="text-sm text-gray-400">Prevents unauthorized assistance</p>
<p className="text-sm text-gray-400 dark:text-gray-400">Prevents unauthorized assistance</p>
</div>
<XCircle className="h-6 w-6 text-yellow-400 animate-pulse" />
</div>
@@ -666,7 +666,7 @@ Redirecting to exam interface...`)
<AlertTriangle className="h-8 w-8 text-red-400 animate-pulse" />
<div className="flex-1">
<span className="text-lg font-medium">Virtual environments will be detected</span>
<p className="text-sm text-gray-400">Ensures exam integrity</p>
<p className="text-sm text-gray-400 dark:text-gray-400">Ensures exam integrity</p>
</div>
<Shield className="h-6 w-6 text-red-400 animate-bounce" />
</div>
@@ -691,12 +691,12 @@ Redirecting to exam interface...`)
</button>
{/* Security notice */}
<div className="mt-6 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded-xl animate-fade-in animate-delay-500">
<div className="mt-6 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded-xl animate-fade-in animate-delay-500 dark:bg-yellow-900/30 dark:border-yellow-500/50">
<div className="flex items-center space-x-2 mb-2">
<AlertTriangle className="w-5 h-5 text-yellow-400 animate-pulse" />
<span className="font-semibold text-yellow-300">Security Notice</span>
</div>
<p className="text-sm text-yellow-200">
<p className="text-sm text-yellow-200 dark:text-yellow-200">
This exam uses advanced security measures. Browser restrictions will be enforced during the examination period.
</p>
</div>
@@ -708,7 +708,7 @@ Redirecting to exam interface...`)
// Enhanced Main Exam Interface
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-black text-white animate-fade-in relative overflow-hidden">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#24467d] dark:to-[#4a2f86] text-gray-900 dark:text-white animate-fade-in relative overflow-hidden">
{/* Animated background elements */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-0 left-0 w-96 h-96 bg-blue-500 rounded-full mix-blend-overlay animate-blob"></div>
@@ -772,11 +772,11 @@ Redirecting to exam interface...`)
<div className="flex-1 h-1 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full animate-pulse"></div>
</div>
<p className="mb-6 text-lg text-gray-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<p className="mb-6 text-lg text-gray-300 dark:text-gray-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
Write a function that converts a string to uppercase.
</p>
<div className="bg-black/50 p-6 rounded-xl transform transition-all duration-300 hover:bg-black/60 animate-slide-up border border-gray-600 hover:border-blue-500/50" style={{ animationDelay: '0.2s' }}>
<div className="bg-blue-950/35 p-6 rounded-xl transform transition-all duration-300 hover:bg-blue-900/40 animate-slide-up border border-blue-300/25 hover:border-blue-300/60" style={{ animationDelay: '0.2s' }}>
<pre className="text-green-400 font-mono text-lg">
{`def capitalize_string(text):
# Your code here
@@ -807,11 +807,11 @@ Redirecting to exam interface...`)
{/* Editor status indicators */}
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2 px-3 py-1 bg-green-900/30 rounded-full">
<div className="flex items-center space-x-2 px-3 py-1 bg-green-900/30 rounded-full dark:bg-green-900/30">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-green-300">Ready</span>
<span className="text-sm text-green-300 dark:text-green-300">Ready</span>
</div>
<div className="text-sm text-gray-400 font-mono">
<div className="text-sm text-gray-400 dark:text-gray-400 font-mono">
Lines: {code.split('\n').length} | Chars: {code.length}
</div>
</div>
@@ -822,7 +822,7 @@ Redirecting to exam interface...`)
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="def capitalize_string(text):\n # Your code here\n pass"
className="w-full h-80 bg-black/70 text-green-400 font-mono p-6 rounded-xl border-2 border-gray-600 resize-none transition-all duration-300 focus:border-green-500 focus:ring-4 focus:ring-green-500/20 hover:border-gray-500 animate-slide-up backdrop-blur-sm"
className="w-full h-80 bg-blue-950/55 text-green-300 font-mono p-6 rounded-xl border-2 border-blue-300/25 resize-none transition-all duration-300 focus:border-green-400 focus:ring-4 focus:ring-green-500/20 hover:border-blue-300/50 animate-slide-up backdrop-blur-sm"
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
@@ -842,7 +842,7 @@ Redirecting to exam interface...`)
</div>
{/* Line numbers overlay */}
<div className="absolute left-2 top-6 text-gray-500 font-mono text-sm select-none pointer-events-none">
<div className="absolute left-2 top-6 text-gray-500 dark:text-gray-600 font-mono text-sm select-none pointer-events-none">
{Array.from({ length: code.split('\n').length }, (_, i) => (
<div key={i} className="h-6 leading-6">
{i + 1}
@@ -897,14 +897,14 @@ Redirecting to exam interface...`)
</div>
{/* Code statistics */}
<div className="flex items-center space-x-4 text-sm text-gray-400">
<div className="flex items-center space-x-4 text-sm text-gray-400 dark:text-gray-400">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
<span>Python 3.9</span>
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-400 rounded-full animate-pulse"></div>
<span className="text-white dark:text-white">Python 3.9</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-400" />
<span>Syntax OK</span>
<CheckCircle className="w-4 h-4 text-green-400 dark:text-green-400" />
<span className="text-white dark:text-white">Syntax OK</span>
</div>
</div>
</div>
@@ -925,18 +925,18 @@ Redirecting to exam interface...`)
<div className="p-3 bg-yellow-600/20 rounded-xl animate-bounce">
<Trophy className="h-8 w-8 text-yellow-400 animate-pulse" />
</div>
<h3 className="text-2xl font-bold">Leaderboard</h3>
<h3 className="text-2xl font-bold dark:text-white">Leaderboard</h3>
<div className="flex-1 h-1 bg-gradient-to-r from-yellow-500 to-orange-500 rounded-full animate-pulse"></div>
</div>
{/* Leaderboard stats */}
<div className="mb-6 p-4 bg-black/30 rounded-xl border border-gray-600">
<div className="mb-6 p-4 bg-blue-950/25 rounded-xl border border-blue-300/25 dark:border-blue-300/25">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-400">Total Participants</span>
<span className="text-sm text-gray-400 dark:text-gray-400">Total Participants</span>
<span className="font-bold text-blue-400">{leaderboard.length}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Completed</span>
<span className="text-sm text-gray-400 dark:text-gray-400">Completed</span>
<span className="font-bold text-green-400">
{leaderboard.filter(p => p.completed).length}
</span>
@@ -1003,7 +1003,7 @@ Redirecting to exam interface...`)
{/* Submission time */}
{participant.submitted_at && (
<div className="text-xs text-gray-400">
<div className="text-xs text-gray-400 dark:text-gray-400">
Submitted: {new Date(participant.submitted_at).toLocaleTimeString()}
</div>
)}
@@ -1013,7 +1013,7 @@ Redirecting to exam interface...`)
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-white/5 to-transparent transition-transform duration-700"></div>
</div>
)) : (
<div className="text-center py-8 text-gray-400 animate-pulse">
<div className="text-center py-8 text-gray-400 dark:text-gray-400 animate-pulse">
<Users className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>No participants yet</p>
</div>
+11 -11
View File
@@ -224,26 +224,26 @@ fn main() {
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
{/* Header */}
<div className="bg-gray-800 border-b border-gray-700 p-4">
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 shadow">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">OpenLearnX Real Compiler</h1>
<p className="text-gray-400">Execute code in multiple programming languages with real output</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OpenLearnX Real Compiler</h1>
<p className="text-gray-600 dark:text-gray-400">Execute code in multiple programming languages with real output</p>
</div>
<div className="flex items-center space-x-4">
<button
onClick={testCompiler}
className="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded flex items-center space-x-2"
className="bg-purple-600 dark:bg-purple-700 hover:bg-purple-700 dark:hover:bg-purple-800 px-4 py-2 rounded flex items-center space-x-2 text-white"
>
<Settings className="h-4 w-4" />
<span>Test Compiler</span>
</button>
<div className="text-sm text-gray-400">
<div className="text-sm text-gray-600 dark:text-gray-400">
{languages.length} languages supported
</div>
</div>
@@ -256,14 +256,14 @@ fn main() {
{/* Code Editor */}
<div className="space-y-4">
{/* Language Selector & Controls */}
<div className="bg-gray-800 rounded-lg p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 shadow">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold">Code Editor</h2>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Code Editor</h2>
<div className="flex items-center space-x-2">
<select
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value)}
className="bg-gray-700 text-white px-3 py-1 rounded border border-gray-600"
className="bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white px-3 py-1 rounded border border-gray-300 dark:border-gray-600"
>
{languages.map(lang => (
<option key={lang.id} value={lang.id}>
@@ -281,14 +281,14 @@ fn main() {
/>
<label
htmlFor="file-upload"
className="bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded cursor-pointer"
className="bg-gray-600 dark:bg-gray-600 hover:bg-gray-700 dark:hover:bg-gray-700 px-3 py-1 rounded cursor-pointer text-white"
>
<Upload className="h-4 w-4" />
</label>
<button
onClick={downloadCode}
className="bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded"
className="bg-gray-600 dark:bg-gray-600 hover:bg-gray-700 dark:hover:bg-gray-700 px-3 py-1 rounded text-white"
>
<Download className="h-4 w-4" />
</button>
@@ -51,7 +51,7 @@ export default function LessonDetailPage() {
const router = useRouter()
const courseId = params?.courseId ?? ''
const lessonId = params?.lessonId ?? ''
const { user, firebaseUser, isLoading: isAuthLoading } = useAuth()
const { user, isLoading: isAuthLoading } = useAuth()
const [course, setCourse] = useState<Course | null>(null)
const [modules, setModules] = useState<Module[]>([])
@@ -61,16 +61,16 @@ export default function LessonDetailPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isAuthLoading && !user && !firebaseUser) {
if (!isAuthLoading && !user) {
toast.error("Please login to view lessons.")
router.replace("/")
return
}
if ((user || firebaseUser) && courseId) {
if (user && courseId) {
fetchCourseData()
}
}, [user, firebaseUser, isAuthLoading, router, courseId])
}, [user, isAuthLoading, router, courseId])
const fetchCourseData = async () => {
setLoading(true)
+195 -463
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter, useParams } from "next/navigation"
import { Loader2, Play, Clock, BookOpen, ChevronDown, ChevronRight, User, Users, Star, Award, TrendingUp, CheckCircle, ArrowRight } from "lucide-react"
import { Loader2, Play, Clock, BookOpen, ChevronDown, ChevronRight, User, Users, Star, CheckCircle } from "lucide-react"
import { toast } from "react-hot-toast"
import api from "@/lib/api"
import { useAuth } from "@/context/auth-context"
@@ -44,7 +44,7 @@ type Lesson = {
}
export default function CoursePage() {
const { user, firebaseUser, isLoading: authLoading } = useAuth()
const { user, isLoading: authLoading } = useAuth()
const params = useParams()
const router = useRouter()
const courseId = params?.courseId as string
@@ -56,42 +56,49 @@ export default function CoursePage() {
const [modulesLoading, setModulesLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Navigation state
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null)
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null)
const [expandedModules, setExpandedModules] = useState<{ [moduleId: string]: boolean }>({})
const [completed, setCompleted] = useState(false)
// Certificate Modal State
const [showCertificateModal, setShowCertificateModal] = useState(false)
const logCourseActivity = async (action: "view" | "start" | "lesson_view", lessonId?: string) => {
try {
const token = localStorage.getItem("openlearnx_jwt_token")
await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/activity`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ action, lesson_id: lessonId }),
})
} catch {
// Activity logging should not block course UX.
}
}
useEffect(() => {
if (!authLoading && !user && !firebaseUser) {
if (!authLoading && !user) {
toast.error("Please login to view courses.")
router.replace("/")
return
}
if ((user || firebaseUser) && courseId) {
if (user && courseId) {
fetchCourseData()
}
}, [authLoading, user, firebaseUser, courseId, router])
}, [authLoading, user, courseId, router])
const fetchCourseData = async () => {
setLoading(true)
setError(null)
try {
console.log('🔍 Starting to fetch course data for:', courseId)
const courseResponse = await api.get<Course>(`/api/courses/${courseId}?t=${Date.now()}`)
const courseData = courseResponse.data
console.log('✅ Course data loaded:', courseData)
setCourse(courseData)
setCourse(courseResponse.data)
logCourseActivity("view")
await fetchModulesAndLessons(courseId)
} catch (err: any) {
console.error('❌ Error fetching course data:', err)
setError(err.message || "Failed to load course data.")
toast.error("Failed to load course data.")
} finally {
@@ -99,60 +106,37 @@ export default function CoursePage() {
}
}
const fetchModulesAndLessons = async (courseId: string) => {
const fetchModulesAndLessons = async (id: string) => {
setModulesLoading(true)
try {
console.log('🔍 Fetching modules for course:', courseId)
let modulesData = null
let modulesResponse = null
// Use public endpoint for course page (not admin endpoint)
try {
modulesResponse = await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/modules`, {
headers: {
'Content-Type': 'application/json'
}
const modulesResponse = await fetch(`http://127.0.0.1:5000/api/courses/${id}/modules`, {
headers: { "Content-Type": "application/json" },
})
if (modulesResponse.ok) {
modulesData = await modulesResponse.json()
console.log('✅ Modules loaded from public endpoint:', modulesData)
}
} catch (publicError) {
console.error('❌ Module endpoint failed')
if (!modulesResponse.ok) {
setModules([])
setLessons({})
return
}
if (modulesData) {
const modulesData = await modulesResponse.json()
let modulesList: Module[] = []
if (modulesData.success && modulesData.modules && Array.isArray(modulesData.modules)) {
modulesList = modulesData.modules
} else if (modulesData.modules && Array.isArray(modulesData.modules)) {
modulesList = modulesData.modules
} else if (Array.isArray(modulesData)) {
modulesList = modulesData
} else if (modulesData.data && Array.isArray(modulesData.data)) {
modulesList = modulesData.data
}
if (modulesData.success && Array.isArray(modulesData.modules)) modulesList = modulesData.modules
else if (Array.isArray(modulesData.modules)) modulesList = modulesData.modules
else if (Array.isArray(modulesData)) modulesList = modulesData
else if (Array.isArray(modulesData.data)) modulesList = modulesData.data
modulesList = modulesList.sort((a, b) => a.order - b.order)
console.log('🔍 Processed modules list:', modulesList)
setModules(modulesList)
if (modulesList.length > 0) {
await fetchLessonsForAllModules(modulesList)
}
} else {
console.log('⚠️ No modules data received')
setModules([])
setLessons({})
}
} catch (error) {
console.error('❌ Error in fetchModulesAndLessons:', error)
} catch {
setModules([])
setLessons({})
} finally {
@@ -166,42 +150,26 @@ export default function CoursePage() {
for (const module of modulesList) {
try {
console.log('🔍 Fetching lessons for module:', module.id)
// Use public endpoint for course page (not admin endpoint)
const lessonsResponse = await fetch(`http://127.0.0.1:5000/api/modules/${module.id}/lessons`, {
headers: {
'Content-Type': 'application/json'
}
headers: { "Content-Type": "application/json" },
})
if (lessonsResponse.ok) {
const lessonData = await lessonsResponse.json()
console.log(`✅ Lessons loaded for module ${module.id}:`, lessonData)
let lessonsList: Lesson[] = []
if (lessonData.success && lessonData.lessons && Array.isArray(lessonData.lessons)) {
lessonsList = lessonData.lessons
} else if (lessonData.lessons && Array.isArray(lessonData.lessons)) {
lessonsList = lessonData.lessons
} else if (Array.isArray(lessonData)) {
lessonsList = lessonData
} else if (lessonData.data && Array.isArray(lessonData.data)) {
lessonsList = lessonData.data
}
lessonsList = lessonsList.sort((a, b) => a.order - b.order)
lessonsData[module.id] = lessonsList
if (!selectedModuleId && lessonsList.length > 0) {
expandedState[module.id] = true
}
} else {
console.log(`⚠️ No lessons found for module ${module.id}`)
if (!lessonsResponse.ok) {
lessonsData[module.id] = []
continue
}
} catch (error) {
console.error(`❌ Error fetching lessons for module ${module.id}:`, error)
const lessonData = await lessonsResponse.json()
let lessonsList: Lesson[] = []
if (lessonData.success && Array.isArray(lessonData.lessons)) lessonsList = lessonData.lessons
else if (Array.isArray(lessonData.lessons)) lessonsList = lessonData.lessons
else if (Array.isArray(lessonData)) lessonsList = lessonData
else if (Array.isArray(lessonData.data)) lessonsList = lessonData.data
lessonsData[module.id] = lessonsList.sort((a, b) => a.order - b.order)
if (!selectedModuleId && lessonsData[module.id].length > 0) expandedState[module.id] = true
} catch {
lessonsData[module.id] = []
}
}
@@ -212,63 +180,51 @@ export default function CoursePage() {
if (!selectedModuleId && modulesList.length > 0) {
const firstModule = modulesList[0]
const firstModuleLessons = lessonsData[firstModule.id] || []
setSelectedModuleId(firstModule.id)
if (firstModuleLessons.length > 0) {
setSelectedLessonId(firstModuleLessons[0].id)
}
if (firstModuleLessons.length > 0) setSelectedLessonId(firstModuleLessons[0].id)
}
}
function getEmbedUrl(url?: string): string | undefined {
const getEmbedUrl = (url?: string): string | undefined => {
if (!url) return undefined
const regExp = /(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/))([^#&?]{11})/
const match = url.match(regExp)
if (match && match[1]) {
return `https://www.youtube.com/embed/${match[1]}?rel=0&modestbranding=1`
}
if (match && match[1]) return `https://www.youtube.com/embed/${match[1]}?rel=0&modestbranding=1`
return url
}
const toggleModule = (moduleId: string) => {
setExpandedModules(prev => ({
...prev,
[moduleId]: !prev[moduleId]
}))
setExpandedModules((prev) => ({ ...prev, [moduleId]: !prev[moduleId] }))
}
const selectLesson = (moduleId: string, lessonId: string) => {
setSelectedModuleId(moduleId)
setSelectedLessonId(lessonId)
setExpandedModules(prev => ({
...prev,
[moduleId]: true
}))
setExpandedModules((prev) => ({ ...prev, [moduleId]: true }))
logCourseActivity("lesson_view", lessonId)
}
const getCurrentLesson = (): Lesson | null => {
if (!selectedModuleId || !selectedLessonId) return null
const moduleLessons = lessons[selectedModuleId] || []
return moduleLessons.find(lesson => lesson.id === selectedLessonId) || null
return (lessons[selectedModuleId] || []).find((lesson) => lesson.id === selectedLessonId) || null
}
const getAllLessons = (): Lesson[] => {
const allLessons: Lesson[] = []
modules.forEach(module => {
const moduleLessons = lessons[module.id] || []
allLessons.push(...moduleLessons)
const all: Lesson[] = []
modules.forEach((module) => {
all.push(...(lessons[module.id] || []))
})
return allLessons
return all
}
const navigateLesson = (direction: 'prev' | 'next') => {
const navigateLesson = (direction: "prev" | "next") => {
const allLessons = getAllLessons()
const currentIndex = allLessons.findIndex(lesson => lesson.id === selectedLessonId)
const currentIndex = allLessons.findIndex((lesson) => lesson.id === selectedLessonId)
if (direction === 'prev' && currentIndex > 0) {
if (direction === "prev" && currentIndex > 0) {
const prevLesson = allLessons[currentIndex - 1]
selectLesson(prevLesson.module_id, prevLesson.id)
} else if (direction === 'next' && currentIndex < allLessons.length - 1) {
} else if (direction === "next" && currentIndex < allLessons.length - 1) {
const nextLesson = allLessons[currentIndex + 1]
selectLesson(nextLesson.module_id, nextLesson.id)
}
@@ -284,37 +240,35 @@ export default function CoursePage() {
return allLessons.length > 0 && allLessons[allLessons.length - 1].id === selectedLessonId
}
const markComplete = () => {
const markComplete = async () => {
try {
const token = localStorage.getItem("openlearnx_jwt_token")
if (selectedLessonId) {
await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/lessons/${selectedLessonId}/complete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
}
} catch {
// Keep UX smooth even if completion log write fails.
}
setCompleted(true)
setShowCertificateModal(true)
}
const getTotalLessons = () => {
return Object.values(lessons).reduce((total, moduleLessons) => total + moduleLessons.length, 0)
}
const getTotalLessons = () => Object.values(lessons).reduce((total, moduleLessons) => total + moduleLessons.length, 0)
const currentLesson = getCurrentLesson()
if (authLoading || loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 flex items-center justify-center relative overflow-hidden">
{/* Animated background elements */}
<div className="absolute inset-0">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-yellow-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse animation-delay-1000"></div>
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse animation-delay-2000"></div>
</div>
<div className="text-center z-10">
<div className="relative">
<Loader2 className="h-16 w-16 animate-spin text-white mx-auto mb-6 drop-shadow-lg" />
<div className="absolute inset-0 h-16 w-16 border-4 border-transparent border-t-purple-400 rounded-full animate-ping mx-auto"></div>
</div>
<p className="text-xl text-white font-semibold tracking-wide animate-pulse">Loading your learning journey...</p>
<div className="mt-4 flex justify-center space-x-1">
<div className="w-2 h-2 bg-white rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-white rounded-full animate-bounce animation-delay-200"></div>
<div className="w-2 h-2 bg-white rounded-full animate-bounce animation-delay-400"></div>
</div>
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-3" />
<p className="text-gray-700 dark:text-gray-300">Loading course...</p>
</div>
</div>
)
@@ -322,236 +276,132 @@ export default function CoursePage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-900 via-pink-900 to-purple-900 flex items-center justify-center p-4">
<div className="text-center max-w-md mx-auto px-6">
<div className="bg-white/10 backdrop-blur-lg border border-red-300/30 rounded-3xl p-10 shadow-2xl animate-bounce">
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
<span className="text-3xl"></span>
</div>
<h2 className="text-2xl font-bold text-white mb-4">Oops! Something went wrong</h2>
<p className="text-red-200 mb-8 leading-relaxed">{error}</p>
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="w-full max-w-md rounded-xl border border-red-200 bg-white dark:bg-gray-800 p-6 text-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Unable to load course</h2>
<p className="mt-2 text-sm text-red-600 dark:text-red-300">{error}</p>
<button
onClick={fetchCourseData}
className="px-8 py-4 bg-gradient-to-r from-red-500 to-pink-500 text-white rounded-2xl hover:from-red-600 hover:to-pink-600 shadow-lg transition-all duration-300 transform hover:scale-105 font-semibold text-lg"
className="mt-4 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
>
Try Again
Retry
</button>
</div>
</div>
</div>
)
}
if (!course) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center p-4">
<div className="text-center max-w-sm bg-white/10 backdrop-blur-lg rounded-3xl shadow-2xl p-10 animate-fadeIn">
<div className="w-24 h-24 bg-gray-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce">
<span className="text-4xl">🔍</span>
</div>
<h2 className="text-3xl font-bold text-white mb-4">Course Not Found</h2>
<p className="text-gray-300 leading-relaxed">The course you're looking for doesn't exist or may have been removed.</p>
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white dark:bg-gray-800 p-6 text-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Course not found</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">This course is unavailable or was removed.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50">
{/* Animated Background Elements */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float"></div>
<div className="absolute -bottom-40 -left-40 w-96 h-96 bg-yellow-300 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float animation-delay-2000"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-pink-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
</div>
{/* Header */}
<header className="bg-white/80 backdrop-blur-lg shadow-xl border-b border-purple-200 sticky top-0 z-50">
<div className="w-full px-6 sm:px-10 lg:px-16 xl:px-20">
<div className="flex items-center justify-between h-20">
<div className="flex items-center space-x-6 animate-slideInLeft">
<div className="w-14 h-14 bg-gradient-to-br from-purple-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg transform hover:scale-110 transition-transform duration-300">
<span className="text-white font-extrabold text-2xl">OL</span>
</div>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="w-full px-6 sm:px-8 lg:px-12 py-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent tracking-tight">{course.title}</h1>
<p className="text-sm text-purple-700 font-semibold tracking-wide">by {course.mentor}</p>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">{course.title}</h1>
<p className="text-sm text-gray-600 dark:text-gray-300">by {course.mentor}</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1.5">
<BookOpen className="w-4 h-4" />
<span>{modules.length} modules</span>
</div>
<div className="hidden md:flex items-center space-x-8 text-sm text-purple-700 animate-slideInRight">
<div className="flex items-center space-x-2 bg-purple-100 px-4 py-2 rounded-full">
<BookOpen className="w-5 h-5 text-purple-600" />
<span className="font-semibold">{modules.length} modules</span>
</div>
<div className="flex items-center space-x-2 bg-indigo-100 px-4 py-2 rounded-full">
<Play className="w-5 h-5 text-indigo-600" />
<span className="font-semibold">{getTotalLessons()} lessons</span>
</div>
<div className="flex items-center space-x-2 bg-pink-100 px-4 py-2 rounded-full">
<Users className="w-5 h-5 text-pink-600" />
<span className="font-semibold">{course.students.toLocaleString()} students</span>
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1.5">
<Play className="w-4 h-4" />
<span>{getTotalLessons()} lessons</span>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1.5">
<Users className="w-4 h-4" />
<span>{course.students.toLocaleString()} students</span>
</div>
</div>
</div>
</header>
<main className="w-full px-6 sm:px-10 lg:px-16 xl:px-20 py-12 grid grid-cols-1 lg:grid-cols-5 gap-12 relative z-10">
<main className="w-full px-6 sm:px-8 lg:px-12 py-6 grid grid-cols-1 lg:grid-cols-5 gap-6">
<aside className="lg:col-span-2">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 sticky top-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Course Content</h2>
{/* Sidebar - Now takes up 2 columns on large screens */}
<aside className="lg:col-span-2 animate-slideInLeft">
<div className="bg-white/80 backdrop-blur-lg rounded-3xl shadow-2xl border border-purple-200 p-10 sticky top-28">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-extrabold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">Course Content</h2>
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center">
<BookOpen className="w-4 h-4 text-white" />
</div>
</div>
{/* Enhanced Progress Bar */}
<div className="mb-8 p-4 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-2xl border border-purple-200">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-purple-700">Progress</span>
<span className="text-sm font-bold text-indigo-600">25%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div className="bg-gradient-to-r from-purple-500 to-indigo-500 h-3 rounded-full transition-all duration-1000 ease-out animate-pulse" style={{width: '25%'}}></div>
</div>
</div>
{/* Debug Info - Enhanced */}
<div className="mb-8 p-5 bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-2xl animate-fadeIn">
<h3 className="text-sm font-bold text-blue-800 mb-4 flex items-center">
<TrendingUp className="w-4 h-4 mr-2" />
🔍 Debug Info:
</h3>
<div className="text-xs space-y-2 text-blue-700">
<p><strong>Course ID:</strong> {courseId}</p>
<p><strong>Modules Loaded:</strong> {modules.length}</p>
<p><strong>Total Lessons:</strong> {getTotalLessons()}</p>
<p><strong>Modules Loading:</strong> {modulesLoading ? 'Yes' : 'No'}</p>
<p><strong>Selected Module:</strong> {selectedModuleId || 'None'}</p>
<p><strong>Selected Lesson:</strong> {currentLesson?.title || 'None'}</p>
<p><strong>Expanded Modules:</strong> {Object.keys(expandedModules).length}</p>
</div>
{modules.length > 0 && (
<details className="mt-4 border-t border-blue-200 pt-4">
<summary className="text-xs cursor-pointer text-blue-600 font-semibold hover:text-blue-800 transition-colors">Show Raw Data</summary>
<pre className="mt-3 text-xs p-4 bg-white rounded-xl shadow max-h-40 overflow-auto">
{JSON.stringify({ modules, lessons }, null, 2)}
</pre>
</details>
)}
</div>
{/* Loading State */}
{modulesLoading && (
<div className="text-center py-10 animate-pulse">
<Loader2 className="h-12 w-12 animate-spin text-purple-500 mx-auto mb-4" />
<p className="text-lg text-purple-700 font-semibold">Loading modules...</p>
<div className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-blue-600 mx-auto mb-2" />
<p className="text-sm text-gray-600 dark:text-gray-300">Loading modules...</p>
</div>
)}
{/* No Modules State */}
{!modulesLoading && modules.length === 0 && (
<div className="text-center py-8 animate-bounce">
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-300 rounded-2xl p-6 text-yellow-800">
<div className="w-16 h-16 bg-yellow-500 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">📚</span>
</div>
<h3 className="text-lg font-bold mb-3">No Modules Found</h3>
<p className="text-sm mb-4 leading-relaxed">
This could mean:<br />
&bull; No modules created yet<br />
&bull; API endpoint issues<br />
&bull; Course ID mismatch
<div className="rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 p-5 text-center">
<h3 className="text-base font-semibold text-gray-900 dark:text-white">No content available yet</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Lessons for this course have not been published.
</p>
<button
onClick={() => fetchModulesAndLessons(courseId)}
className="px-6 py-3 bg-gradient-to-r from-yellow-500 to-orange-500 rounded-2xl text-white font-bold hover:from-yellow-600 hover:to-orange-600 transition-all duration-300 transform hover:scale-105 shadow-lg"
className="mt-4 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
>
Retry Loading Modules
Refresh
</button>
</div>
</div>
)}
{/* Modules List */}
{!modulesLoading && modules.length > 0 && (
<div className="space-y-4">
<div className="space-y-3">
{modules.map((module, index) => (
<div key={module.id} className="border border-purple-200 rounded-2xl overflow-hidden shadow-lg bg-white/60 backdrop-blur-sm hover:shadow-xl transition-all duration-300 animate-fadeInUp" style={{animationDelay: `${index * 100}ms`}}>
{/* Module Header */}
<div key={module.id} className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden">
<button
onClick={() => toggleModule(module.id)}
className={`w-full px-6 py-5 text-left hover:bg-gradient-to-r hover:from-purple-50 hover:to-indigo-50 flex items-center justify-between transition-all duration-300 ${
selectedModuleId === module.id ? 'bg-gradient-to-r from-purple-100 to-indigo-100 border-purple-300' : 'bg-white/80'
className={`w-full px-4 py-3 text-left flex items-center justify-between ${
selectedModuleId === module.id ? "bg-blue-50 dark:bg-blue-900/20" : "bg-white dark:bg-gray-800"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-4">
<span className="flex-shrink-0 w-10 h-10 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full flex items-center justify-center font-bold text-sm shadow-lg transform hover:scale-110 transition-transform duration-300">
{index + 1}
</span>
<h3 className="font-bold text-purple-900 truncate text-lg">{module.title}</h3>
</div>
<p className="text-sm text-purple-600 mt-2 ml-14 flex items-center">
<CheckCircle className="w-4 h-4 mr-2" />
{(lessons[module.id]?.length ?? 0) + (lessons[module.id]?.length === 1 ? ' lesson' : ' lessons')}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{index + 1}. {module.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{(lessons[module.id]?.length || 0)} lessons
</p>
</div>
<div className="flex-shrink-0 ml-4">
<div className={`transform transition-transform duration-300 ${expandedModules[module.id] ? 'rotate-180' : ''}`}>
{expandedModules[module.id] ? (
<ChevronDown className="w-6 h-6 text-purple-500" />
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-6 h-6 text-purple-400" />
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</div>
</div>
</button>
{/* Lessons */}
{expandedModules[module.id] && (
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border-t border-purple-200 animate-slideDown">
{lessons[module.id] && lessons[module.id].length > 0 ? (
lessons[module.id].map((lesson, lessonIndex) => (
<div className="border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50">
{(lessons[module.id] || []).length > 0 ? (
(lessons[module.id] || []).map((lesson) => (
<button
key={lesson.id}
onClick={() => selectLesson(module.id, lesson.id)}
className={`w-full px-8 py-4 text-left hover:bg-gradient-to-r hover:from-purple-100 hover:to-indigo-100 transition-all duration-300 border-l-4 group ${
className={`w-full px-4 py-3 text-left border-l-2 ${
selectedLessonId === lesson.id
? 'border-purple-500 bg-gradient-to-r from-purple-100 to-indigo-100 text-purple-900 font-bold shadow-inner'
: 'border-transparent text-purple-700 hover:border-purple-300'
? "border-blue-600 bg-blue-50 dark:bg-blue-900/20"
: "border-transparent hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
>
<div className="flex items-center space-x-4">
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs transition-all duration-300 group-hover:scale-110 ${
selectedLessonId === lesson.id
? 'bg-gradient-to-r from-purple-500 to-indigo-500 text-white shadow-lg'
: 'bg-purple-200 text-purple-700 group-hover:bg-purple-300'
}`}>
<Play className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate font-semibold">{lesson.title}</p>
<p className="text-sm text-gray-900 dark:text-white">{lesson.title}</p>
{lesson.duration && (
<p className={`text-xs flex items-center mt-1 ${
selectedLessonId === lesson.id ? 'text-purple-700 font-semibold' : 'text-purple-500'
}`}>
<Clock className="w-4 h-4 mr-2" />
{lesson.duration}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 inline-flex items-center gap-1">
<Clock className="w-3 h-3" /> {lesson.duration}
</p>
)}
</div>
<ArrowRight className={`w-4 h-4 transition-all duration-300 ${
selectedLessonId === lesson.id ? 'text-purple-600 transform scale-110' : 'text-transparent group-hover:text-purple-400'
}`} />
</div>
</button>
))
) : (
<p className="px-8 py-6 text-purple-600 text-sm italic text-center">No lessons in this module</p>
<p className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">No lessons in this module.</p>
)}
</div>
)}
@@ -562,14 +412,12 @@ export default function CoursePage() {
</div>
</aside>
{/* Main Content - Now takes up 3 columns on large screens for full width */}
<section className="lg:col-span-3 animate-slideInRight">
<div className="bg-white/80 backdrop-blur-lg rounded-3xl shadow-2xl border border-purple-200 overflow-hidden">
<section className="lg:col-span-3">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
{currentLesson ? (
<>
{/* Video Player */}
{(currentLesson.embed_url || currentLesson.video_url) && (
<div className="aspect-video bg-black rounded-t-3xl overflow-hidden relative group">
<div className="aspect-video bg-black">
<iframe
src={getEmbedUrl(currentLesson.embed_url || currentLesson.video_url)}
title={currentLesson.title}
@@ -577,155 +425,84 @@ export default function CoursePage() {
className="w-full h-full"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</div>
)}
{/* Lesson Content */}
<div className="p-16">
{/* Lesson Header */}
<div className="mb-12 animate-fadeInUp">
<div className="flex items-center text-purple-600 space-x-4 mb-6">
<div className="flex items-center space-x-2 bg-purple-100 px-6 py-3 rounded-full">
<User className="w-6 h-6" />
<span className="font-bold text-lg">{course.mentor}</span>
</div>
<span className="text-purple-300"></span>
<span className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-6 py-3 rounded-full text-lg font-bold uppercase tracking-widest shadow-lg">
<div className="p-6">
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300 mb-4">
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1">
<User className="w-4 h-4" /> {course.mentor}
</span>
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-3 py-1 font-medium">
{course.difficulty}
</span>
</div>
<h1 className="text-6xl font-extrabold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent leading-tight mb-6 drop-shadow-sm">{currentLesson.title}</h1>
{currentLesson.duration && (
<div className="flex items-center text-purple-600 space-x-3 text-xl font-semibold">
<div className="flex items-center space-x-2 bg-purple-100 px-6 py-3 rounded-full">
<Clock className="w-6 h-6" />
<span>{currentLesson.duration}</span>
</div>
</div>
)}
</div>
{/* Lesson Description */}
<h2 className="text-3xl font-semibold text-gray-900 dark:text-white mb-3">{currentLesson.title}</h2>
{currentLesson.description && (
<section className="mb-16 animate-fadeInUp animation-delay-200">
<h2 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent mb-8 border-b-2 border-purple-200 pb-4">
About this lesson
</h2>
<article className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-3xl p-10 text-purple-900 prose max-w-none shadow-inner border border-purple-200 text-lg leading-relaxed">
{currentLesson.description}
</article>
</section>
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">About this lesson</h3>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{currentLesson.description}</p>
</div>
)}
{/* Lesson Content */}
{currentLesson.content && (
<section className="mb-16 animate-fadeInUp animation-delay-400">
<h2 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent mb-8 border-b-2 border-purple-200 pb-4">
Lesson Content
</h2>
<article className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-3xl p-10 text-purple-900 prose max-w-none whitespace-pre-line shadow-inner border border-purple-200 text-lg leading-relaxed">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Lesson notes</h3>
<div className="rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 p-4 text-gray-800 dark:text-gray-200 whitespace-pre-line">
{currentLesson.content}
</article>
</section>
</div>
</div>
)}
{/* Navigation */}
<div className="flex justify-between items-center pt-12 border-t-2 border-purple-200 animate-fadeInUp animation-delay-600">
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3">
<button
onClick={() => navigateLesson('prev')}
onClick={() => navigateLesson("prev")}
disabled={isFirstLesson()}
className="px-12 py-5 bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 rounded-3xl hover:from-gray-200 hover:to-gray-300 disabled:opacity-50 disabled:cursor-not-allowed font-bold transition-all duration-300 transform hover:scale-105 shadow-lg text-xl"
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 disabled:opacity-50 dark:bg-gray-700 dark:text-gray-100"
>
Previous Lesson
Previous
</button>
{!isLastLesson() ? (
<button
onClick={() => navigateLesson('next')}
className="px-12 py-5 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-3xl hover:from-purple-700 hover:to-indigo-700 font-bold transition-all duration-300 transform hover:scale-105 shadow-xl text-xl"
onClick={() => navigateLesson("next")}
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
>
Next Lesson
Next
</button>
) : (
<button
onClick={markComplete}
disabled={completed}
className={`px-12 py-5 rounded-3xl font-bold transition-all duration-300 transform hover:scale-105 shadow-xl text-xl ${
completed
? "bg-gradient-to-r from-green-500 to-emerald-500 text-white cursor-not-allowed shadow-inner"
: "bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:from-purple-700 hover:to-pink-700"
}`}
className={`px-4 py-2 rounded-lg text-white ${completed ? "bg-green-600" : "bg-blue-600 hover:bg-blue-700"}`}
>
{completed ? "✓ Course Completed" : "Mark as Complete"}
{completed ? "Completed" : "Mark as complete"}
</button>
)}
</div>
{/* Completion Message */}
{completed && !showCertificateModal && (
<div className="mt-16 bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-300 rounded-3xl p-12 text-center shadow-2xl animate-bounce">
<div className="text-green-700">
<div className="text-8xl mb-8 animate-pulse">🎉</div>
<h3 className="text-4xl font-extrabold mb-6 bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">Congratulations!</h3>
<p className="mb-10 text-green-800 font-semibold text-2xl">
You have successfully completed this course!
</p>
<button
onClick={() => setShowCertificateModal(true)}
className="px-16 py-6 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-3xl hover:from-green-700 hover:to-emerald-700 transition-all duration-300 transform hover:scale-105 font-bold text-xl shadow-xl"
>
Get Your Certificate 🏆
</button>
</div>
</div>
)}
</div>
</>
) : (
/* Course Overview */
<div className="p-20 text-center max-w-5xl mx-auto text-purple-900 animate-fadeIn">
<h1 className="text-7xl font-extrabold mb-10 bg-gradient-to-r from-purple-600 via-pink-600 to-indigo-600 bg-clip-text text-transparent drop-shadow-lg">{course.title}</h1>
<div className="flex flex-wrap justify-center gap-8 mb-16 text-purple-700 font-bold text-xl">
<div className="flex items-center space-x-4 bg-purple-100 px-8 py-4 rounded-full shadow-lg transform hover:scale-105 transition-transform duration-300">
<User className="w-8 h-8" />
<span>by {course.mentor}</span>
</div>
<div className="flex items-center space-x-4 bg-yellow-100 px-8 py-4 rounded-full shadow-lg transform hover:scale-105 transition-transform duration-300">
<Star className="w-8 h-8 text-yellow-500" />
<span>4.8 Rating</span>
</div>
<span className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-8 py-4 rounded-full text-xl uppercase font-extrabold tracking-widest shadow-lg transform hover:scale-105 transition-transform duration-300">
<div className="p-8 text-center">
<h2 className="text-3xl font-semibold text-gray-900 dark:text-white">{course.title}</h2>
<div className="mt-4 flex flex-wrap justify-center gap-3 text-sm">
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1 text-gray-700 dark:text-gray-300">
<User className="w-4 h-4" /> by {course.mentor}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 px-3 py-1 text-yellow-700 dark:text-yellow-300">
<Star className="w-4 h-4" /> 4.8 rating
</span>
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 px-3 py-1 text-blue-700 dark:text-blue-300">
{course.difficulty}
</span>
</div>
<p className="text-3xl max-w-5xl mx-auto mb-16 leading-relaxed tracking-wide text-purple-800">{course.description}</p>
<div className="grid grid-cols-3 gap-16 mb-16">
<div className="text-center transform hover:scale-110 transition-transform duration-300">
<div className="w-32 h-32 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<span className="text-5xl font-extrabold text-white">{modules.length}</span>
</div>
<div className="uppercase text-purple-700 font-bold tracking-wide text-xl">Modules</div>
</div>
<div className="text-center transform hover:scale-110 transition-transform duration-300">
<div className="w-32 h-32 bg-gradient-to-r from-pink-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<span className="text-5xl font-extrabold text-white">{getTotalLessons()}</span>
</div>
<div className="uppercase text-purple-700 font-bold tracking-wide text-xl">Lessons</div>
</div>
<div className="text-center transform hover:scale-110 transition-transform duration-300">
<div className="w-32 h-32 bg-gradient-to-r from-indigo-500 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<span className="text-5xl font-extrabold text-white">{course.students.toLocaleString()}</span>
</div>
<div className="uppercase text-purple-700 font-bold tracking-wide text-xl">Students</div>
</div>
</div>
<p className="mt-6 text-gray-700 dark:text-gray-300 max-w-3xl mx-auto">{course.description}</p>
{(course.embed_url || course.video_url) && (
<div className="aspect-video rounded-3xl overflow-hidden shadow-2xl mx-auto max-w-6xl bg-black border-4 border-purple-600 mb-16 transform hover:scale-105 transition-transform duration-500">
<div className="mt-8 aspect-video rounded-xl overflow-hidden border border-gray-200 dark:border-gray-600 bg-black max-w-4xl mx-auto">
<iframe
src={getEmbedUrl(course.embed_url || course.video_url)}
title={course.title}
@@ -741,23 +518,18 @@ export default function CoursePage() {
onClick={() => {
const firstModule = modules[0]
const firstLessons = lessons[firstModule?.id] || []
if (firstLessons.length > 0) {
if (firstModule && firstLessons.length > 0) {
logCourseActivity("start")
selectLesson(firstModule.id, firstLessons[0].id)
}
}}
className="mt-12 px-20 py-8 bg-gradient-to-r from-purple-600 via-pink-600 to-indigo-600 text-white rounded-3xl hover:from-purple-700 hover:via-pink-700 hover:to-indigo-700 font-extrabold text-3xl shadow-2xl transition-all duration-300 transform hover:scale-110 hover:shadow-purple-500/25"
className="mt-8 px-6 py-3 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
>
🚀 Start Learning Journey
Start learning
</button>
) : (
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border-2 border-yellow-300 rounded-3xl p-12 text-yellow-800 text-2xl font-bold max-w-lg mx-auto shadow-xl">
<div className="w-24 h-24 bg-yellow-500 rounded-full flex items-center justify-center mx-auto mb-8">
<span className="text-4xl">🚧</span>
</div>
<h3 className="text-3xl mb-6">Coming Soon</h3>
<p className="font-normal text-yellow-700 text-xl">
Amazing lessons are being crafted for this course. Check back soon!
</p>
<div className="mt-8 rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 p-5 max-w-xl mx-auto">
<p className="text-gray-700 dark:text-gray-300">Lessons are not published yet for this course.</p>
</div>
)}
</div>
@@ -766,7 +538,6 @@ export default function CoursePage() {
</section>
</main>
{/* Certificate Modal */}
{showCertificateModal && course && (
<CertificateModal
isOpen={showCertificateModal}
@@ -774,49 +545,10 @@ export default function CoursePage() {
courseTitle={course.title}
courseMentor={course.mentor}
courseId={course.id}
userId={user?.uid || firebaseUser?.uid || 'anonymous'}
walletId={user?.wallet || firebaseUser?.uid || 'no-wallet'}
userId={user?.id || "anonymous"}
walletId={user?.wallet_address || "no-wallet"}
/>
)}
{/* Custom CSS for animations */}
<style jsx>{`
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-50px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(50px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideDown {
from { opacity: 0; max-height: 0; }
to { opacity: 1; max-height: 500px; }
}
.animate-float { animation: float 6s ease-in-out infinite; }
.animate-fadeIn { animation: fadeIn 1s ease-out; }
.animate-fadeInUp { animation: fadeInUp 0.8s ease-out; }
.animate-slideInLeft { animation: slideInLeft 0.8s ease-out; }
.animate-slideInRight { animation: slideInRight 0.8s ease-out; }
.animate-slideDown { animation: slideDown 0.3s ease-out; }
.animation-delay-200 { animation-delay: 0.2s; }
.animation-delay-400 { animation-delay: 0.4s; }
.animation-delay-600 { animation-delay: 0.6s; }
.animation-delay-1000 { animation-delay: 1s; }
.animation-delay-2000 { animation-delay: 2s; }
`}</style>
</div>
)
}
+583 -124
View File
@@ -3,6 +3,7 @@
import { useAuth } from "@/context/auth-context"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "react-hot-toast"
import {
User,
LogOut,
@@ -19,46 +20,249 @@ import {
Activity,
Edit3,
Save,
X
X,
Loader2,
Github,
Linkedin,
Twitter,
Link2,
Flame,
Upload
} from "lucide-react"
import api from "@/lib/api"
type ActivityData = {
id: string
type: string
title: string
description: string
completed_at: string
timestamp_utc?: string
points_earned?: number
}
export default function DashboardPage() {
const { user, firebaseUser, walletConnected, logout, authMethod } = useAuth()
const { user, walletConnected, logout, authMethod } = useAuth()
const router = useRouter()
const normalizedRole = String(user?.role || 'student').toLowerCase()
const roleLabel = normalizedRole === 'admin' ? 'Admin' : normalizedRole === 'instructor' ? 'Instructor' : 'Student'
const roleBadgeClass =
normalizedRole === 'admin'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
: normalizedRole === 'instructor'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'
const [isEditingProfile, setIsEditingProfile] = useState(false)
const [isLoadingStats, setIsLoadingStats] = useState(true)
const [isEditingSocial, setIsEditingSocial] = useState(false)
const [isUploadingImage, setIsUploadingImage] = useState(false)
const [showAllActivities, setShowAllActivities] = useState(false)
const [recentActivity, setRecentActivity] = useState<ActivityData[]>([])
const [profileData, setProfileData] = useState({
name: user?.name || '',
bio: user?.bio || '',
avatar: user?.avatar || ''
})
const [stats, setStats] = useState({
coursesCompleted: 12,
totalXP: 2450,
currentStreak: 7,
rank: 156,
certificatesEarned: 3,
hoursLearned: 45
const [socialData, setSocialData] = useState({
github: '',
linkedin: '',
twitter: ''
})
const [stats, setStats] = useState({
coursesCompleted: 0,
totalXP: 0,
currentStreak: 0,
bestStreak: 0,
rank: 0,
certificatesEarned: 0,
hoursLearned: 0,
lastActiveDate: new Date().toISOString()
})
// Fetch real stats from API
useEffect(() => {
if (!user && !firebaseUser) {
if (!user) {
router.replace("/auth/login")
return
}
}, [user, firebaseUser, router])
fetchRealStats()
}, [user, router])
const fetchRealStats = async () => {
setIsLoadingStats(true)
try {
const [statsResponse, activityResponse] = await Promise.all([
api.get("/api/dashboard/comprehensive-stats"),
api.get("/api/dashboard/recent-activity"),
])
if (statsResponse.data.success && statsResponse.data.data) {
const data = statsResponse.data.data
const streakData = data.streak_data || {}
setStats({
coursesCompleted: data.courses_completed || 0,
totalXP: data.total_xp || 0,
currentStreak: streakData.current_streak || 0,
bestStreak: streakData.best_streak || 0,
rank: data.global_rank || 0,
certificatesEarned: data.blockchain?.certificates || 0,
hoursLearned: Math.round(data.learning_analytics?.time_spent_hours || 0),
lastActiveDate: data.last_active_date || new Date().toISOString()
})
}
if (activityResponse.data?.success && Array.isArray(activityResponse.data?.data)) {
setRecentActivity(activityResponse.data.data)
}
} catch (error: any) {
console.error("Failed to fetch dashboard stats:", error)
// Keep default values if fetch fails
toast.error("Failed to load dashboard data")
} finally {
setIsLoadingStats(false)
}
}
const handleSettingsClick = () => {
setIsEditingSocial(false)
setIsEditingProfile(true)
const el = document.getElementById("profile-card")
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" })
}
}
const activityIconConfig = (activityType: string) => {
const t = String(activityType || "").toLowerCase()
if (t.includes("course")) return { icon: BookOpen, bgColor: "bg-green-100", textColor: "text-green-600" }
if (t.includes("quiz")) return { icon: Award, bgColor: "bg-blue-100", textColor: "text-blue-600" }
if (t.includes("streak")) return { icon: Flame, bgColor: "bg-orange-100", textColor: "text-orange-600" }
if (t.includes("rank")) return { icon: TrendingUp, bgColor: "bg-purple-100", textColor: "text-purple-600" }
if (t.includes("account") || t.includes("auth")) return { icon: Settings, bgColor: "bg-indigo-100", textColor: "text-indigo-600" }
return { icon: Activity, bgColor: "bg-slate-100", textColor: "text-slate-600" }
}
const isPlaceholderActivity = (item: ActivityData) => {
const text = `${item.title || ""} ${item.description || ""}`.toLowerCase()
const fakeMarkers = [
"completed react fundamentals",
"scored 95% on javascript quiz",
"7-day learning streak achieved",
"moved up 5 positions in leaderboard",
]
return fakeMarkers.some((marker) => text.includes(marker))
}
const realActivities = recentActivity.filter((item) => !isPlaceholderActivity(item))
const visibleActivities = showAllActivities ? realActivities : realActivities.slice(0, 6)
const handleProfileUpdate = async () => {
try {
// Here you would call your API to update profile
// await updateProfile(profileData)
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
if (!token) {
toast.error("Not authenticated")
return
}
const response = await api.post(
"/api/auth/profile/update",
{
name: profileData.name,
bio: profileData.bio,
avatar: profileData.avatar
},
{
headers: {
"Authorization": `Bearer ${token}`
}
}
)
if (response.data.success) {
setIsEditingProfile(false)
console.log("Profile updated:", profileData)
} catch (error) {
toast.success("Profile updated successfully")
// Update local user context if available
console.log("Profile updated:", response.data.user)
} else {
toast.error(response.data.error || "Failed to update profile")
}
} catch (error: any) {
console.error("Failed to update profile:", error)
toast.error(error.response?.data?.error || "Failed to update profile")
}
}
if (!user && !firebaseUser) {
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg']
if (!allowedTypes.includes(file.type)) {
toast.error('Only PNG and JPG formats are allowed')
return
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB')
return
}
setIsUploadingImage(true)
try {
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
if (!token) {
toast.error("Not authenticated")
return
}
const formData = new FormData()
formData.append('file', file)
const response = await api.post(
"/api/auth/upload-image",
formData,
{
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "multipart/form-data"
}
}
)
if (response.data.success) {
setProfileData({
...profileData,
avatar: response.data.image
})
toast.success("Image uploaded successfully")
} else {
toast.error(response.data.error || "Failed to upload image")
}
} catch (error: any) {
console.error("Image upload error:", error)
toast.error(error.response?.data?.error || "Failed to upload image")
} finally {
setIsUploadingImage(false)
}
}
const handleSocialUpdate = async () => {
try {
// Here you would call your API to update social links
// await updateSocialLinks(socialData)
console.log("Social links updated:", socialData)
toast.success("Social links updated successfully")
} catch (error) {
console.error("Failed to update social links:", error)
toast.error("Failed to update social links")
}
}
if (!user) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
@@ -67,10 +271,10 @@ export default function DashboardPage() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
{/* Professional Header */}
<header className="bg-white shadow-lg border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<header className="bg-white dark:bg-gray-950 shadow-lg border-b border-gray-100 dark:border-gray-800">
<div className="w-full px-4 sm:px-6 lg:px-10">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
@@ -81,13 +285,17 @@ export default function DashboardPage() {
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
OpenLearnX
</h1>
<p className="text-xs text-gray-500">Learn Earn Grow</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Learn Earn Grow</p>
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<button className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-xl transition-all duration-200">
<button
onClick={handleSettingsClick}
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-all duration-200"
title="Open profile settings"
>
<Settings className="w-5 h-5" />
</button>
<button
@@ -103,30 +311,36 @@ export default function DashboardPage() {
</header>
{/* Main Dashboard Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className="w-full px-4 sm:px-6 lg:px-10 py-8">
{/* Welcome Section */}
<div className="mb-8">
<div className="bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 rounded-2xl p-8 text-white shadow-xl">
<div className="bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 dark:from-indigo-700 dark:via-purple-700 dark:to-blue-700 rounded-2xl p-8 text-white shadow-xl">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold mb-2">
Welcome back! 👋
Welcome back
</h2>
<p className="text-indigo-100 text-lg">
<p className="text-indigo-100 dark:text-indigo-200 text-lg">
Ready to continue your learning journey?
</p>
{authMethod === "metamask" && user ? (
<div className="mt-3 flex items-center space-x-2">
<Wallet className="w-4 h-4 text-orange-300" />
<span className="text-sm text-indigo-100">
<span className="text-sm text-indigo-100 dark:text-indigo-200">
Connected: {user.wallet_address.slice(0, 6)}...{user.wallet_address.slice(-4)}
</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeClass}`}>
{roleLabel}
</span>
</div>
) : firebaseUser && (
) : (
<div className="mt-3 flex items-center space-x-2">
<Mail className="w-4 h-4 text-blue-300" />
<span className="text-sm text-indigo-100">
{firebaseUser.email}
<span className="text-sm text-indigo-100 dark:text-indigo-200">
{user.email || user.id}
</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeClass}`}>
{roleLabel}
</span>
</div>
)}
@@ -142,11 +356,28 @@ export default function DashboardPage() {
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
{isLoadingStats ? (
// Loading skeleton
<>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded w-24 mb-3"></div>
<div className="h-8 bg-gray-200 rounded w-32"></div>
</div>
<div className="w-16 h-16 bg-gray-200 rounded-xl"></div>
</div>
</div>
))}
</>
) : (
<>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Total XP</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.totalXP.toLocaleString()}</p>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Total XP</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{stats.totalXP.toLocaleString()}</p>
</div>
<div className="p-4 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl shadow-lg">
<Trophy className="w-8 h-8 text-white" />
@@ -158,11 +389,11 @@ export default function DashboardPage() {
</div>
</div>
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Courses</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.coursesCompleted}</p>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Courses</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{stats.coursesCompleted}</p>
</div>
<div className="p-4 bg-gradient-to-r from-green-500 to-teal-500 rounded-xl shadow-lg">
<BookOpen className="w-8 h-8 text-white" />
@@ -174,26 +405,26 @@ export default function DashboardPage() {
</div>
</div>
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Streak</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.currentStreak} days</p>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Streak</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{stats.currentStreak} days</p>
</div>
<div className="p-4 bg-gradient-to-r from-orange-500 to-red-500 rounded-xl shadow-lg">
<Target className="w-8 h-8 text-white" />
<Flame className="w-8 h-8 text-white" />
</div>
</div>
<div className="flex items-center mt-4">
<span className="text-sm text-orange-600 font-medium">🔥 Keep it up!</span>
<span className="text-sm text-orange-600 font-medium">Keep your streak going</span>
</div>
</div>
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Global Rank</p>
<p className="text-3xl font-bold text-gray-900 mt-1">#{stats.rank}</p>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Global Rank</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">#{stats.rank}</p>
</div>
<div className="p-4 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl shadow-lg">
<BarChart3 className="w-8 h-8 text-white" />
@@ -204,27 +435,52 @@ export default function DashboardPage() {
<span className="text-sm text-purple-600 font-medium">Top 5% learner</span>
</div>
</div>
</>
)}
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Profile Card with Edit Functionality */}
<div className="lg:col-span-1">
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900">Profile</h3>
<div id="profile-card" className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden">
{/* Profile Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setIsEditingProfile(!isEditingProfile)}
className="p-2 text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all duration-200"
onClick={() => { setIsEditingProfile(false); setIsEditingSocial(false); }}
className={`flex-1 py-3 px-4 text-sm font-semibold transition-all ${
!isEditingSocial ? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 bg-indigo-50 dark:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
{isEditingProfile ? <X className="w-5 h-5" /> : <Edit3 className="w-5 h-5" />}
Profile
</button>
<button
onClick={() => setIsEditingSocial(true)}
className={`flex-1 py-3 px-4 text-sm font-semibold transition-all ${
isEditingSocial ? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 bg-indigo-50 dark:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Social Links
</button>
</div>
<div className="p-6">
{!isEditingSocial ? (
/* Profile Tab */
<>
<div className="text-center mb-6">
<div className="w-20 h-20 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
<User className="w-10 h-10 text-white" />
{profileData.avatar ? (
<img
src={profileData.avatar}
alt="Avatar"
className="w-24 h-24 rounded-full mx-auto mb-4 border-4 border-indigo-100 object-cover"
/>
) : (
<div className="w-24 h-24 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
<User className="w-12 h-12 text-white" />
</div>
)}
{isEditingProfile ? (
<div className="space-y-3">
<input
@@ -232,28 +488,77 @@ export default function DashboardPage() {
value={profileData.name}
onChange={(e) => setProfileData({...profileData, name: e.target.value})}
placeholder="Your name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center"
/>
{/* Image Upload Input */}
<div className="relative">
<input
type="file"
accept="image/png,image/jpeg"
onChange={handleImageUpload}
disabled={isUploadingImage}
className="hidden"
id="avatar-upload"
/>
<label
htmlFor="avatar-upload"
className={`w-full flex items-center justify-center space-x-2 px-3 py-2 border-2 border-dashed border-indigo-300 dark:border-indigo-500 rounded-lg hover:bg-indigo-50 dark:hover:bg-gray-700 cursor-pointer transition-colors ${
isUploadingImage ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{isUploadingImage ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-indigo-600" />
<span className="text-sm text-indigo-600 dark:text-indigo-400">Uploading...</span>
</>
) : (
<>
<Upload className="w-4 h-4 text-indigo-600" />
<span className="text-sm text-indigo-600 dark:text-indigo-400">Upload PNG/JPG</span>
</>
)}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">Max 5MB (PNG or JPG only)</p>
</div>
<textarea
value={profileData.bio}
onChange={(e) => setProfileData({...profileData, bio: e.target.value})}
placeholder="Your bio"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center h-20 resize-none"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center h-20 resize-none"
/>
<div className="flex gap-2">
<button
onClick={handleProfileUpdate}
className="flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors mx-auto"
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-700 dark:hover:bg-indigo-800 text-white rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
<span>Save</span>
</button>
<button
onClick={() => setIsEditingProfile(false)}
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<X className="w-4 h-4" />
<span>Cancel</span>
</button>
</div>
</div>
) : (
<div>
<h4 className="text-lg font-semibold text-gray-900">
<div className="flex items-center justify-center gap-2 mb-2">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
{profileData.name || "Your Name"}
</h4>
<p className="text-gray-600 text-sm mt-1">
<button
onClick={() => setIsEditingProfile(true)}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
>
<Edit3 className="w-4 h-4" />
</button>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm mt-2">
{profileData.bio || "Add a bio to tell others about yourself"}
</p>
</div>
@@ -261,7 +566,7 @@ export default function DashboardPage() {
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-100">
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-gray-700 dark:to-gray-800 rounded-xl border border-indigo-100 dark:border-gray-600">
<div className="flex items-center space-x-3">
{authMethod === "metamask" ? (
<Wallet className="w-6 h-6 text-orange-600" />
@@ -269,8 +574,8 @@ export default function DashboardPage() {
<Mail className="w-6 h-6 text-blue-600" />
)}
<div>
<p className="text-sm font-semibold text-gray-900">Auth Method</p>
<p className="text-xs text-gray-600">
<p className="text-sm font-semibold text-gray-900 dark:text-white">Auth Method</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
{authMethod === "metamask" ? "MetaMask Wallet" : "Email Account"}
</p>
</div>
@@ -282,88 +587,242 @@ export default function DashboardPage() {
</div>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-xl">
<Calendar className="w-6 h-6 text-blue-600 mx-auto mb-2" />
<p className="text-2xl font-bold text-blue-900">{stats.hoursLearned}</p>
<p className="text-xs text-blue-600 font-medium">Hours Learned</p>
<div className="text-center p-4 bg-blue-50 dark:bg-gray-700 rounded-xl">
<Calendar className="w-6 h-6 text-blue-600 dark:text-blue-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-blue-900 dark:text-blue-300">{stats.hoursLearned}</p>
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">Hours Learned</p>
</div>
<div className="text-center p-4 bg-green-50 rounded-xl">
<Award className="w-6 h-6 text-green-600 mx-auto mb-2" />
<p className="text-2xl font-bold text-green-900">{stats.certificatesEarned}</p>
<p className="text-xs text-green-600 font-medium">Certificates</p>
<div className="text-center p-4 bg-green-50 dark:bg-gray-700 rounded-xl">
<Award className="w-6 h-6 text-green-600 dark:text-green-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-green-900 dark:text-green-300">{stats.certificatesEarned}</p>
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Certificates</p>
</div>
</div>
</div>
</>
) : (
/* Social Links Tab */
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Connect Your Social Accounts</h4>
{isEditingSocial && (
<div className="space-y-4">
<div className="space-y-2">
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<Github className="w-5 h-5 text-gray-800 dark:text-gray-400" />
<span>GitHub Profile</span>
</label>
<input
type="text"
value={socialData.github}
onChange={(e) => setSocialData({...socialData, github: e.target.value})}
placeholder="username or profile URL"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<Linkedin className="w-5 h-5 text-blue-600" />
<span>LinkedIn Profile</span>
</label>
<input
type="text"
value={socialData.linkedin}
onChange={(e) => setSocialData({...socialData, linkedin: e.target.value})}
placeholder="username or profile URL"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<Twitter className="w-5 h-5 text-blue-400" />
<span>Twitter Profile</span>
</label>
<input
type="text"
value={socialData.twitter}
onChange={(e) => setSocialData({...socialData, twitter: e.target.value})}
placeholder="@username or profile URL"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
/>
</div>
<button
onClick={() => {
handleSocialUpdate()
setIsEditingSocial(false)
}}
className="w-full flex items-center justify-center space-x-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-700 dark:hover:bg-indigo-800 text-white rounded-lg transition-colors mt-6"
>
<Save className="w-4 h-4" />
<span>Save Links</span>
</button>
</div>
)}
{!isEditingSocial ? (
<>
<div className="space-y-3">
{socialData.github && (
<a
href={socialData.github.startsWith('http') ? socialData.github : `https://github.com/${socialData.github}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all"
>
<Github className="w-5 h-5 text-gray-800 dark:text-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{socialData.github}</span>
<Link2 className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</a>
)}
{socialData.linkedin && (
<a
href={socialData.linkedin.startsWith('http') ? socialData.linkedin : `https://linkedin.com/in/${socialData.linkedin}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all"
>
<Linkedin className="w-5 h-5 text-blue-600" />
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{socialData.linkedin}</span>
<Link2 className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</a>
)}
{socialData.twitter && (
<a
href={socialData.twitter.startsWith('http') ? socialData.twitter : `https://twitter.com/${socialData.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all"
>
<Twitter className="w-5 h-5 text-blue-400" />
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{socialData.twitter}</span>
<Link2 className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</a>
)}
</div>
{!socialData.github && !socialData.linkedin && !socialData.twitter && (
<div className="text-center py-8 px-4 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-300">No social links added yet</p>
<button
onClick={() => setIsEditingSocial(true)}
className="mt-3 text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-semibold"
>
Add your first link
</button>
</div>
)}
<button
onClick={() => setIsEditingSocial(true)}
className="w-full mt-4 flex items-center justify-center space-x-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<Edit3 className="w-4 h-4" />
<span>Edit Links</span>
</button>
</>
) : null}
</div>
)}
</div>
</div>
{/* Streak Calendar */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Learning Streak</h3>
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-3xl font-bold text-orange-600 dark:text-orange-400">{stats.currentStreak}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">days in a row</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600 dark:text-gray-400">Best streak</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.bestStreak} days</p>
</div>
</div>
{/* GitHub-style contribution graph */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3">Last 12 weeks</p>
<div className="grid grid-cols-12 gap-1">
{[...Array(84)].map((_, i) => {
// Calculate activity based on current streak
let activity = 0
if (stats.currentStreak > 0) {
// Days in current streak show full activity
if (i >= 84 - stats.currentStreak) {
activity = 0.85 + Math.random() * 0.15
} else {
// Past days show decreasing activity
activity = Math.random() * 0.4
}
} else {
// No streak - show light activity
activity = Math.random() * 0.3
}
let bgColor = 'bg-gray-100 dark:bg-gray-700'
if (activity > 0.75) bgColor = 'bg-green-600'
else if (activity > 0.5) bgColor = 'bg-green-400'
else if (activity > 0.25) bgColor = 'bg-green-200'
else if (activity > 0) bgColor = 'bg-green-100'
return (
<div
key={i}
className={`w-3 h-3 rounded-sm ${bgColor} cursor-pointer hover:ring-2 hover:ring-offset-1 dark:hover:ring-offset-gray-800 hover:ring-green-600 transition-all`}
title={`Week ${Math.floor(i / 7) + 1}`}
/>
)
})}
</div>
<div className="mt-4 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>Less</span>
<div className="flex gap-1">
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-700 rounded-sm"></div>
<div className="w-3 h-3 bg-green-100 dark:bg-green-900 rounded-sm"></div>
<div className="w-3 h-3 bg-green-400 dark:bg-green-600 rounded-sm"></div>
<div className="w-3 h-3 bg-green-600 dark:bg-green-500 rounded-sm"></div>
</div>
<span>More</span>
</div>
</div>
</div>
</div>
{/* Recent Activity */}
<div className="lg:col-span-2">
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900">Recent Activity</h3>
<button className="text-sm text-indigo-600 hover:text-indigo-800 font-semibold hover:bg-indigo-50 px-3 py-1 rounded-lg transition-all duration-200">
View all
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Recent Activity</h3>
<button
onClick={() => setShowAllActivities((prev) => !prev)}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-semibold hover:bg-indigo-50 dark:hover:bg-gray-700 px-3 py-1 rounded-lg transition-all duration-200"
>
{showAllActivities ? "Show less" : "View all →"}
</button>
</div>
<div className="space-y-4">
{[
{
type: "course",
title: "Completed React Fundamentals",
time: "2 hours ago",
icon: BookOpen,
color: "green",
bgColor: "bg-green-100",
textColor: "text-green-600"
},
{
type: "quiz",
title: "Scored 95% on JavaScript Quiz",
time: "1 day ago",
icon: Award,
color: "blue",
bgColor: "bg-blue-100",
textColor: "text-blue-600"
},
{
type: "streak",
title: "7-day learning streak!",
time: "Today",
icon: Target,
color: "orange",
bgColor: "bg-orange-100",
textColor: "text-orange-600"
},
{
type: "rank",
title: "Moved up 5 positions in leaderboard",
time: "2 days ago",
icon: TrendingUp,
color: "purple",
bgColor: "bg-purple-100",
textColor: "text-purple-600"
},
].map((activity, index) => (
<div key={index} className="flex items-center space-x-4 p-4 hover:bg-gray-50 rounded-xl transition-all duration-200 border border-gray-100 hover:border-gray-200 hover:shadow-md">
<div className={`p-3 rounded-xl ${activity.bgColor} shadow-sm`}>
<activity.icon className={`w-5 h-5 ${activity.textColor}`} />
{visibleActivities.map((activity) => {
const iconConfig = activityIconConfig(activity.type)
const Icon = iconConfig.icon
return (
<div key={activity.id} className="flex items-center space-x-4 p-4 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-xl transition-all duration-200 border border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-md">
<div className={`p-3 rounded-xl ${iconConfig.bgColor} shadow-sm`}>
<Icon className={`w-5 h-5 ${iconConfig.textColor}`} />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">{activity.title}</p>
<p className="text-xs text-gray-500 mt-1">{activity.time}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{activity.title}</p>
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1">{activity.description}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{activity.timestamp_utc || activity.completed_at}</p>
</div>
<div className="w-2 h-2 bg-gray-300 rounded-full"></div>
<div className="w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-100">
<h4 className="text-sm font-semibold text-indigo-900 mb-2">🚀 Keep Learning!</h4>
<p className="text-xs text-indigo-700">
You're doing great! Complete 2 more courses this week to maintain your streak.
</p>
)})}
{realActivities.length === 0 && (
<div className="text-sm text-gray-500 dark:text-gray-400">No recent activity yet.</div>
)}
</div>
</div>
</div>
+40 -13
View File
@@ -45,46 +45,73 @@
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--background: 223 49% 18%;
--foreground: 210 40% 98%;
--card: 218 36% 22%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover: 220 35% 20%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary: 220 32% 28%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted: 220 28% 24%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent: 220 32% 30%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--border: 220 30% 34%;
--input: 220 30% 34%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-background: 224 42% 16%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent: 222 33% 24%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-border: 220 30% 34%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer utilities {
.dark .dark\:bg-gray-950,
.dark .dark\:bg-gray-900,
.dark .dark\:bg-gray-800,
.dark .bg-gray-900,
.dark .bg-gray-800,
.dark .bg-black,
.dark .bg-black\/70,
.dark .bg-black\/60,
.dark .bg-black\/50,
.dark .bg-black\/30 {
background-color: #22314a !important;
}
.dark .dark\:border-gray-800,
.dark .dark\:border-gray-700,
.dark .border-gray-700,
.dark .border-gray-600 {
border-color: rgba(96, 165, 250, 0.24) !important;
}
}
@layer base {
* {
@apply border-border;
}
html {
font-size: 16px;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-size: clamp(15px, 0.92rem + 0.12vw, 16px);
line-height: 1.5;
}
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { Toaster } from "react-hot-toast"
import { AuthProvider } from "@/context/auth-context"
import { Navbar } from "@/components/ui/navbar"
import { ThemeProvider } from "@/components/theme-provider"
import { AccountStatusGuard } from "@/components/account-status-guard"
const inter = Inter({ subsets: ["latin"] })
@@ -26,8 +27,9 @@ export default function RootLayout({
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AuthProvider>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
<div className="min-h-screen bg-background text-foreground">
<Navbar />
<AccountStatusGuard />
<main className="transition-all duration-300">{children}</main>
<Toaster
position="top-right"
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link"
export default function NotFoundPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-sky-100 dark:from-slate-900 dark:via-blue-950 dark:to-slate-900 p-6">
<section className="w-full max-w-xl rounded-2xl border border-blue-200/70 dark:border-blue-900 bg-white/90 dark:bg-slate-900/90 p-8 shadow-xl">
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 dark:text-blue-300">ERROR 404</p>
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Page not found</h1>
<p className="mt-3 text-sm text-slate-600 dark:text-slate-300">
The page you requested does not exist or may have been moved.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
>
Go Home
</Link>
<Link
href="/dashboard"
className="rounded-lg border border-blue-300 px-4 py-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-950/50"
>
Open Dashboard
</Link>
</div>
</section>
</main>
)
}
+14 -14
View File
@@ -311,18 +311,18 @@ export default function QuizHostPanel() {
if (!currentRoom) {
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="max-w-4xl mx-auto p-6">
<div className="text-center mb-8">
<Crown className="h-16 w-16 text-yellow-400 mx-auto mb-4" />
<h1 className="text-4xl font-bold mb-4">👑 Quiz Host Panel</h1>
<p className="text-gray-400">
<Crown className="h-16 w-16 text-yellow-500 mx-auto mb-4" />
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">👑 Quiz Host Panel</h1>
<p className="text-gray-600 dark:text-gray-300">
Create and manage adaptive quizzes with AI-powered questions
</p>
</div>
<div className="bg-gray-800 p-6 rounded-lg">
<h2 className="text-xl font-bold mb-4">Create New Quiz Room</h2>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 shadow">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Create New Quiz Room</h2>
<div className="space-y-4">
<input
@@ -330,7 +330,7 @@ export default function QuizHostPanel() {
placeholder="Your name (Host)"
value={roomForm.host_name}
onChange={(e) => setRoomForm(prev => ({...prev, host_name: e.target.value}))}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<input
@@ -338,17 +338,17 @@ export default function QuizHostPanel() {
placeholder="Quiz room title"
value={roomForm.room_title}
onChange={(e) => setRoomForm(prev => ({...prev, room_title: e.target.value}))}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="flex items-center space-x-2">
<label className="flex items-center space-x-2 text-gray-900 dark:text-white">
<input
type="checkbox"
checked={roomForm.is_private}
onChange={(e) => setRoomForm(prev => ({...prev, is_private: e.target.checked}))}
className="rounded"
className="rounded accent-blue-600 dark:accent-blue-500"
/>
<span>Private Room (requires code)</span>
</label>
@@ -359,7 +359,7 @@ export default function QuizHostPanel() {
placeholder="Max participants"
value={roomForm.max_participants}
onChange={(e) => setRoomForm(prev => ({...prev, max_participants: parseInt(e.target.value) || 50}))}
className="p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
min="1"
max="100"
/>
@@ -369,7 +369,7 @@ export default function QuizHostPanel() {
placeholder="Duration (minutes)"
value={roomForm.duration_minutes}
onChange={(e) => setRoomForm(prev => ({...prev, duration_minutes: parseInt(e.target.value) || 30}))}
className="p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
min="5"
max="180"
/>
@@ -378,7 +378,7 @@ export default function QuizHostPanel() {
<button
onClick={createRoom}
disabled={!roomForm.host_name || !roomForm.room_title}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 disabled:from-gray-600 disabled:to-gray-600 p-4 rounded-lg font-semibold"
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 dark:from-purple-700 dark:to-blue-700 dark:hover:from-purple-800 dark:hover:to-blue-800 disabled:from-gray-400 disabled:to-gray-400 dark:disabled:from-gray-600 dark:disabled:to-gray-600 p-4 rounded-lg font-semibold text-white"
>
🚀 Create Quiz Room
</button>
@@ -390,7 +390,7 @@ export default function QuizHostPanel() {
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="bg-gray-800 p-4 rounded-lg mb-6">
+30 -19
View File
@@ -49,12 +49,23 @@ export default function QuizJoinPage() {
setLoading(true)
try {
const token = localStorage.getItem("openlearnx_jwt_token")
const storedUserRaw = localStorage.getItem("openlearnx_user")
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (token) {
headers.Authorization = `Bearer ${token}`
}
const response = await fetch('http://127.0.0.1:5000/api/quizzes/join-room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({
room_code: code,
username: username.trim()
username: username.trim(),
wallet_address: storedUser?.wallet_address,
user_id: storedUser?.id
})
})
@@ -83,25 +94,25 @@ export default function QuizJoinPage() {
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="max-w-4xl mx-auto p-6">
<div className="text-center mb-8">
<Users className="h-16 w-16 text-blue-400 mx-auto mb-4" />
<h1 className="text-4xl font-bold mb-4">🎯 Join Quiz</h1>
<p className="text-gray-400">
<Users className="h-16 w-16 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">🎯 Join Quiz</h1>
<p className="text-gray-600 dark:text-gray-300">
Join an adaptive quiz and test your knowledge!
</p>
</div>
{/* Username Input */}
<div className="bg-gray-800 p-6 rounded-lg mb-6">
<h2 className="text-xl font-bold mb-4">👤 Enter Your Name</h2>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">👤 Enter Your Name</h2>
<input
type="text"
placeholder="Your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
maxLength={20}
/>
</div>
@@ -110,10 +121,10 @@ export default function QuizJoinPage() {
<div className="flex space-x-1 mb-6">
<button
onClick={() => setJoinMode('public')}
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 ${
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 transition-colors ${
joinMode === 'public'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
? 'bg-blue-600 dark:bg-blue-700 text-white'
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-400 dark:hover:bg-gray-600'
}`}
>
<Globe className="h-5 w-5" />
@@ -121,10 +132,10 @@ export default function QuizJoinPage() {
</button>
<button
onClick={() => setJoinMode('code')}
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 ${
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 transition-colors ${
joinMode === 'code'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
? 'bg-blue-600 dark:bg-blue-700 text-white'
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-400 dark:hover:bg-gray-600'
}`}
>
<Lock className="h-5 w-5" />
@@ -134,9 +145,9 @@ export default function QuizJoinPage() {
{/* Join with Code */}
{joinMode === 'code' && (
<div className="bg-gray-800 p-6 rounded-lg">
<h2 className="text-xl font-bold mb-4 flex items-center space-x-2">
<Lock className="h-5 w-5 text-yellow-400" />
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 shadow">
<h2 className="text-xl font-bold mb-4 flex items-center space-x-2 text-gray-900 dark:text-white">
<Lock className="h-5 w-5 text-yellow-500" />
<span>🔐 Join with Room Code</span>
</h2>
@@ -146,7 +157,7 @@ export default function QuizJoinPage() {
placeholder="Enter room code (e.g., ABC123)"
value={roomCode}
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
className="flex-1 p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="flex-1 p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
maxLength={6}
/>
<button
+16 -3
View File
@@ -60,7 +60,12 @@ export default function QuizPlayPage() {
const fetchNextQuestion = async () => {
try {
setLoading(true)
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/next-question`)
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/next-question`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {})
}
})
const data = await response.json()
console.log('Next question response:', data) // ✅ Debug log
@@ -98,12 +103,20 @@ export default function QuizPlayPage() {
try {
setLoading(true)
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
const storedUserRaw = localStorage.getItem("openlearnx_user")
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/submit-answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({
answer: selectedAnswer,
question_data: currentQuestion
question_data: currentQuestion,
user_id: storedUser?.id,
wallet_address: storedUser?.wallet_address
})
})
+18 -9
View File
@@ -96,12 +96,21 @@ export default function QuizTaking() {
setSubmitting(true)
try {
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
const storedUserRaw = localStorage.getItem("openlearnx_user")
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/${quizId}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({
answers,
participant_name: 'User' // You can get this from auth context
participant_name: storedUser?.name || storedUser?.username || 'User',
user_id: storedUser?.id,
wallet_address: storedUser?.wallet_address
})
})
@@ -120,7 +129,7 @@ export default function QuizTaking() {
if (loading) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto mb-4"></div>
<p>Loading AI Quiz...</p>
@@ -131,7 +140,7 @@ export default function QuizTaking() {
if (error) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white flex items-center justify-center">
<div className="text-center">
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<p className="text-xl mb-4">{error}</p>
@@ -148,7 +157,7 @@ export default function QuizTaking() {
if (results) {
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
<div className="max-w-4xl mx-auto p-6">
<div className="text-center mb-8">
<div className="text-6xl mb-4">
@@ -170,9 +179,9 @@ export default function QuizTaking() {
<div className="space-y-4">
{results.ai_feedback.map((feedback: any, index: number) => (
<div key={index} className="bg-gray-900 p-4 rounded border-l-4 border-purple-500">
<h3 className="font-semibold mb-2">Question {index + 1}</h3>
<p className="text-sm text-gray-300 mb-2">{feedback.question}</p>
<div key={index} className="bg-gray-50 dark:bg-gray-900 p-4 rounded border-l-4 border-purple-500">
<h3 className="font-semibold mb-2 text-gray-900 dark:text-white">Question {index + 1}</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">{feedback.question}</p>
<div className="flex items-center space-x-2 mb-2">
{feedback.is_correct ? (
<CheckCircle className="h-4 w-4 text-green-400" />
@@ -213,7 +222,7 @@ export default function QuizTaking() {
const progress = ((currentQuestion + 1) / quiz.questions.length) * 100
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-6">
+12 -12
View File
@@ -87,22 +87,22 @@ export default function CreateQuizPage() {
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="flex items-center space-x-4 mb-8">
<button
onClick={() => router.push('/quizzes')}
className="bg-gray-700 hover:bg-gray-600 p-2 rounded"
className="bg-gray-300 dark:bg-gray-700 hover:bg-gray-400 dark:hover:bg-gray-600 p-2 rounded text-gray-900 dark:text-white"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h1 className="text-3xl font-bold">📝 Create New Quiz</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">📝 Create New Quiz</h1>
</div>
{/* Quiz Details */}
<div className="bg-gray-800 p-6 rounded-lg mb-6">
<h2 className="text-xl font-bold mb-4">Quiz Information</h2>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Quiz Information</h2>
<div className="space-y-4">
<input
@@ -110,21 +110,21 @@ export default function CreateQuizPage() {
placeholder="Quiz title"
value={quiz.title}
onChange={(e) => setQuiz(prev => ({...prev, title: e.target.value}))}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<textarea
placeholder="Quiz description"
value={quiz.description}
onChange={(e) => setQuiz(prev => ({...prev, description: e.target.value}))}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
rows={3}
/>
<select
value={quiz.difficulty}
onChange={(e) => setQuiz(prev => ({...prev, difficulty: e.target.value}))}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="easy">🟢 Easy</option>
<option value="medium">🟡 Medium</option>
@@ -134,20 +134,20 @@ export default function CreateQuizPage() {
</div>
{/* Add Question */}
<div className="bg-gray-800 p-6 rounded-lg mb-6">
<h2 className="text-xl font-bold mb-4">Add Question</h2>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Add Question</h2>
<div className="space-y-4">
<textarea
placeholder="Question text"
value={currentQuestion.question_text}
onChange={(e) => setCurrentQuestion(prev => ({...prev, question_text: e.target.value}))}
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
rows={3}
/>
<div className="space-y-2">
<label className="text-sm font-medium">Options:</label>
<label className="text-sm font-medium text-gray-900 dark:text-white">Options:</label>
{currentQuestion.options.map((option, index) => (
<input
key={index}
+59 -59
View File
@@ -82,43 +82,43 @@ export default function QuizzesPage() {
const getDifficultyColor = (difficulty: string) => {
switch (difficulty.toLowerCase()) {
case 'easy': return 'text-green-400 bg-green-900'
case 'medium': return 'text-yellow-400 bg-yellow-900'
case 'hard': return 'text-red-400 bg-red-900'
default: return 'text-gray-400 bg-gray-700'
case 'easy': return 'text-emerald-800 bg-emerald-100 dark:text-emerald-200 dark:bg-emerald-700/60'
case 'medium': return 'text-amber-800 bg-amber-100 dark:text-amber-200 dark:bg-amber-700/60'
case 'hard': return 'text-rose-800 bg-rose-100 dark:text-rose-200 dark:bg-rose-700/60'
default: return 'text-slate-700 bg-slate-100 dark:text-slate-200 dark:bg-slate-600/60'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'waiting': return 'text-yellow-400 bg-yellow-900'
case 'active': return 'text-green-400 bg-green-900'
case 'completed': return 'text-gray-400 bg-gray-700'
default: return 'text-gray-400 bg-gray-700'
case 'waiting': return 'text-amber-800 bg-amber-100 dark:text-amber-200 dark:bg-amber-700/60'
case 'active': return 'text-emerald-800 bg-emerald-100 dark:text-emerald-200 dark:bg-emerald-700/60'
case 'completed': return 'text-slate-700 bg-slate-100 dark:text-slate-200 dark:bg-slate-600/60'
default: return 'text-slate-700 bg-slate-100 dark:text-slate-200 dark:bg-slate-600/60'
}
}
if (loading && activeTab === 'traditional' && quizzes.length === 0) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="min-h-screen bg-gradient-to-b from-[#dbe8ff] via-[#cfdfff] to-[#d8ccff] dark:from-[#1f3f8a] dark:via-[#2b3f95] dark:to-[#4e2c97] flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto mb-4"></div>
<p>Loading quizzes...</p>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 dark:border-blue-200 mx-auto mb-4"></div>
<p className="text-slate-700 dark:text-blue-100">Loading quizzes...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="min-h-screen bg-gradient-to-b from-[#dbe8ff] via-[#cfdfff] to-[#d8ccff] dark:from-[#1f3f8a] dark:via-[#2b3f95] dark:to-[#4e2c97] text-slate-900 dark:text-white">
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-4 flex items-center justify-center space-x-3">
<h1 className="text-4xl font-bold mb-4 flex items-center justify-center space-x-3 text-slate-900 dark:text-white">
<Trophy className="h-10 w-10 text-yellow-400" />
<span>🧠 OpenLearnX Quiz Platform</span>
</h1>
<p className="text-gray-400 max-w-2xl mx-auto">
<p className="text-slate-600 dark:text-blue-100/90 max-w-2xl mx-auto text-base">
Experience adaptive quizzes with AI-powered questions and real-time difficulty adjustment
</p>
</div>
@@ -135,14 +135,14 @@ export default function QuizzesPage() {
onClick={() => setActiveTab(tab.id as any)}
className={`px-6 py-3 rounded-lg flex items-center space-x-2 transition-colors ${
activeTab === tab.id
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
? 'bg-blue-500 text-white shadow-lg shadow-blue-900/40'
: 'bg-white/70 text-slate-700 hover:bg-white dark:bg-slate-700/60 dark:text-blue-100 dark:hover:bg-slate-600/70'
}`}
>
<tab.icon className="h-5 w-5" />
<div className="text-left">
<div className="font-semibold">{tab.label}</div>
<div className="text-xs opacity-75">{tab.description}</div>
<div className="font-semibold text-slate-900 dark:text-white">{tab.label}</div>
<div className="text-xs opacity-80 text-slate-500 dark:text-blue-100">{tab.description}</div>
</div>
</button>
))}
@@ -155,7 +155,7 @@ export default function QuizzesPage() {
<div className="flex flex-col sm:flex-row gap-4 mb-8 justify-center items-center">
<button
onClick={() => router.push('/quiz-host')}
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-600 hover:to-blue-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
>
<Crown className="h-5 w-5" />
<span>👑 Host a Quiz</span>
@@ -163,7 +163,7 @@ export default function QuizzesPage() {
<button
onClick={() => router.push('/quiz-join')}
className="bg-green-600 hover:bg-green-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
className="bg-emerald-500 hover:bg-emerald-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
>
<Users className="h-5 w-5" />
<span>🎯 Join Quiz</span>
@@ -179,7 +179,7 @@ export default function QuizzesPage() {
</h2>
<button
onClick={fetchPublicRooms}
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center space-x-2"
className="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded flex items-center space-x-2"
>
<span>🔄 Refresh</span>
</button>
@@ -187,19 +187,19 @@ export default function QuizzesPage() {
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p>Loading rooms...</p>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 dark:border-blue-200 mx-auto mb-4"></div>
<p className="text-slate-700 dark:text-blue-100">Loading rooms...</p>
</div>
) : publicRooms.length === 0 ? (
<div className="text-center py-12 bg-gray-800 rounded-lg">
<Globe className="h-16 w-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No Public Rooms Available</h3>
<p className="text-gray-400 mb-6">
<div className="text-center py-12 bg-white/75 dark:bg-[#22314a] rounded-lg border border-blue-200 dark:border-blue-400/20">
<Globe className="h-16 w-16 text-blue-500/60 dark:text-blue-200/60 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2 text-slate-900 dark:text-white">No Public Rooms Available</h3>
<p className="text-slate-600 dark:text-blue-100/85 mb-6">
Be the first to create a public quiz room!
</p>
<button
onClick={() => router.push('/quiz-host')}
className="bg-purple-600 hover:bg-purple-700 px-6 py-3 rounded-lg font-semibold"
className="bg-violet-500 hover:bg-violet-600 px-6 py-3 rounded-lg font-semibold"
>
🚀 Create Room
</button>
@@ -209,7 +209,7 @@ export default function QuizzesPage() {
{publicRooms.map((room) => (
<div
key={room.room_id}
className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-colors border border-gray-700"
className="bg-white/75 dark:bg-[#22314a] rounded-lg p-6 hover:bg-white dark:hover:bg-[#2a3d59] transition-colors border border-blue-200 dark:border-blue-400/20"
>
{/* Room Header */}
<div className="flex items-start justify-between mb-4">
@@ -218,7 +218,7 @@ export default function QuizzesPage() {
<Globe className="h-5 w-5 text-green-400" />
<span>{room.title}</span>
</h3>
<p className="text-gray-400 text-sm">Host: {room.host_name}</p>
<p className="text-slate-600 dark:text-blue-100/80 text-sm">Host: {room.host_name}</p>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(room.status)}`}>
{room.status}
@@ -227,13 +227,13 @@ export default function QuizzesPage() {
{/* Room Stats */}
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
<div className="bg-gray-700 p-3 rounded text-center">
<div className="bg-blue-50 dark:bg-[#1a2740] p-3 rounded text-center">
<div className="font-bold text-blue-400">{room.participants_count}</div>
<div className="text-gray-400">Participants</div>
<div className="text-slate-600 dark:text-blue-100/70">Participants</div>
</div>
<div className="bg-gray-700 p-3 rounded text-center">
<div className="bg-blue-50 dark:bg-[#1a2740] p-3 rounded text-center">
<div className="font-bold text-purple-400">{room.questions_count}</div>
<div className="text-gray-400">Questions</div>
<div className="text-slate-600 dark:text-blue-100/70">Questions</div>
</div>
</div>
@@ -246,7 +246,7 @@ export default function QuizzesPage() {
{/* Room Code */}
<div className="text-center mb-4">
<span className="bg-gray-700 px-3 py-1 rounded font-mono text-blue-400">
<span className="bg-blue-50 dark:bg-[#1a2740] px-3 py-1 rounded font-mono text-blue-500 dark:text-blue-300">
Code: {room.room_code}
</span>
</div>
@@ -254,7 +254,7 @@ export default function QuizzesPage() {
{/* Join Button */}
<button
onClick={() => router.push(`/quiz-join?room=${room.room_code}`)}
className="w-full bg-green-600 hover:bg-green-700 p-3 rounded font-semibold flex items-center justify-center space-x-2"
className="w-full bg-emerald-500 hover:bg-emerald-600 p-3 rounded font-semibold flex items-center justify-center space-x-2"
>
<Play className="h-4 w-4" />
<span>Join Room</span>
@@ -273,31 +273,31 @@ export default function QuizzesPage() {
<div className="max-w-2xl mx-auto mb-8">
<Brain className="h-16 w-16 text-purple-400 mx-auto mb-4" />
<h2 className="text-3xl font-bold mb-4">🧠 Adaptive AI Quiz</h2>
<p className="text-gray-400 mb-6">
<p className="text-slate-600 dark:text-blue-100/85 mb-6">
Experience an intelligent quiz that adapts to your skill level in real-time using our trained CNN model.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-gray-800 p-4 rounded-lg">
<div className="bg-white/75 dark:bg-[#22314a] p-4 rounded-lg border border-blue-200 dark:border-blue-400/20">
<Target className="h-8 w-8 text-blue-400 mx-auto mb-2" />
<h3 className="font-semibold mb-1">Adaptive Difficulty</h3>
<p className="text-sm text-gray-400">
<p className="text-sm text-slate-600 dark:text-blue-100/80">
Questions adjust based on your performance
</p>
</div>
<div className="bg-gray-800 p-4 rounded-lg">
<div className="bg-white/75 dark:bg-[#22314a] p-4 rounded-lg border border-blue-200 dark:border-blue-400/20">
<Brain className="h-8 w-8 text-purple-400 mx-auto mb-2" />
<h3 className="font-semibold mb-1">AI Predictions</h3>
<p className="text-sm text-gray-400">
<p className="text-sm text-slate-600 dark:text-blue-100/80">
See how our AI model would answer
</p>
</div>
<div className="bg-gray-800 p-4 rounded-lg">
<div className="bg-white/75 dark:bg-[#22314a] p-4 rounded-lg border border-blue-200 dark:border-blue-400/20">
<Sparkles className="h-8 w-8 text-green-400 mx-auto mb-2" />
<h3 className="font-semibold mb-1">Smart Analytics</h3>
<p className="text-sm text-gray-400">
<p className="text-sm text-slate-600 dark:text-blue-100/80">
Track performance across difficulty levels
</p>
</div>
@@ -305,7 +305,7 @@ export default function QuizzesPage() {
<button
onClick={() => router.push('/adaptive-quiz')}
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 px-8 py-4 rounded-lg font-semibold flex items-center justify-center space-x-2 mx-auto"
className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-600 hover:to-blue-600 px-8 py-4 rounded-lg font-semibold flex items-center justify-center space-x-2 mx-auto"
>
<Sparkles className="h-5 w-5" />
<span>🚀 Start Adaptive Quiz</span>
@@ -322,7 +322,7 @@ export default function QuizzesPage() {
{aiAvailable && (
<button
onClick={() => router.push('/quizzes/generate')}
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-600 hover:to-blue-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
>
<Brain className="h-5 w-5" />
<Sparkles className="h-4 w-4" />
@@ -332,7 +332,7 @@ export default function QuizzesPage() {
<button
onClick={() => router.push('/quizzes/create')}
className="bg-green-600 hover:bg-green-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
className="bg-emerald-500 hover:bg-emerald-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
>
<Plus className="h-5 w-5" />
<span>Create Manual Quiz</span>
@@ -341,12 +341,12 @@ export default function QuizzesPage() {
{/* AI Status Banner */}
{aiAvailable && (
<div className="bg-gradient-to-r from-purple-900 to-blue-900 border border-purple-600 p-4 rounded-lg mb-8">
<div className="bg-white/75 dark:bg-[#22314a] border border-blue-200 dark:border-blue-400/20 p-4 rounded-lg mb-8">
<div className="flex items-center space-x-3">
<Brain className="h-6 w-6 text-purple-400" />
<Brain className="h-6 w-6 text-purple-300" />
<div>
<h3 className="font-semibold">🤖 AI Service Active</h3>
<p className="text-sm text-gray-300">
<h3 className="font-semibold text-slate-900 dark:text-white">🤖 AI Service Active</h3>
<p className="text-sm text-slate-600 dark:text-blue-100/80">
Our trained CNN model is ready to generate intelligent quizzes and provide feedback
</p>
</div>
@@ -357,15 +357,15 @@ export default function QuizzesPage() {
{/* Traditional Quizzes Grid */}
{quizzes.length === 0 ? (
<div className="text-center py-12">
<Brain className="h-16 w-16 text-gray-600 mx-auto mb-4" />
<Brain className="h-16 w-16 text-blue-500/60 dark:text-blue-200/60 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No Traditional Quizzes Yet</h3>
<p className="text-gray-400 mb-6">
<p className="text-slate-600 dark:text-blue-100/80 mb-6">
Create your first quiz or generate one using AI
</p>
{aiAvailable && (
<button
onClick={() => router.push('/quizzes/generate')}
className="bg-purple-600 hover:bg-purple-700 px-6 py-3 rounded-lg font-semibold"
className="bg-violet-500 hover:bg-violet-600 px-6 py-3 rounded-lg font-semibold"
>
🚀 Generate AI Quiz
</button>
@@ -376,7 +376,7 @@ export default function QuizzesPage() {
{quizzes.map((quiz) => (
<div
key={quiz._id}
className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-colors cursor-pointer"
className="bg-white/75 dark:bg-[#22314a] rounded-lg p-6 hover:bg-white dark:hover:bg-[#2a3d59] transition-colors cursor-pointer border border-blue-200 dark:border-blue-400/20"
onClick={() => router.push(`/quizzes/${quiz.id}`)}
>
{/* Quiz Header */}
@@ -393,12 +393,12 @@ export default function QuizzesPage() {
</div>
{/* Description */}
<p className="text-gray-400 text-sm mb-4 line-clamp-2">
<p className="text-slate-600 dark:text-gray-300 text-sm mb-4 line-clamp-2">
{quiz.description}
</p>
{/* Stats */}
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-blue-100/70">
<div className="flex items-center space-x-4">
<span className="flex items-center space-x-1">
<Users className="h-4 w-4" />
@@ -411,7 +411,7 @@ export default function QuizzesPage() {
</div>
{quiz.generated_by === 'AI' && (
<div className="flex items-center space-x-1 text-purple-400">
<div className="flex items-center space-x-1 text-purple-300">
<Sparkles className="h-3 w-3" />
<span className="text-xs">AI Generated</span>
</div>
@@ -419,8 +419,8 @@ export default function QuizzesPage() {
</div>
{/* Date */}
<div className="mt-3 pt-3 border-t border-gray-700">
<span className="text-xs text-gray-500">
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-400/20">
<span className="text-xs text-slate-500 dark:text-blue-100/70">
Created {new Date(quiz.created_at).toLocaleDateString()}
</span>
</div>
+100 -35
View File
@@ -20,7 +20,6 @@ export function LoginComponent() {
walletConnected,
walletAddress,
user,
firebaseUser,
authMethod
} = useAuth()
@@ -29,23 +28,21 @@ export function LoginComponent() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [isEmailLogin, setIsEmailLogin] = useState(false)
const [isSignup, setIsSignup] = useState(false)
const [username, setUsername] = useState("")
const [isConnectingWallet, setIsConnectingWallet] = useState(false)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle')
// ✅ Check if user is already authenticated
useEffect(() => {
if (!isLoadingAuth) {
if (walletConnected && walletAddress) {
console.log('✅ MetaMask already connected:', walletAddress)
if (user && authMethod) {
console.log('✅ User already authenticated:', authMethod)
setConnectionStatus('connected')
toast.success("Already connected to MetaMask!")
router.push("/dashboard")
} else if (firebaseUser) {
console.log('✅ Firebase user already logged in:', firebaseUser.email)
router.push("/dashboard")
}
}
}, [isLoadingAuth, walletConnected, walletAddress, firebaseUser, router])
}, [isLoadingAuth, user, authMethod, router])
const handleWalletConnect = async () => {
setIsConnectingWallet(true)
@@ -66,7 +63,6 @@ export function LoginComponent() {
if (success) {
setConnectionStatus('connected')
console.log('✅ MetaMask connection successful')
toast.success("MetaMask connected successfully! 🦊")
// Small delay to ensure state is updated
setTimeout(() => {
@@ -74,19 +70,10 @@ export function LoginComponent() {
}, 1000)
} else {
setConnectionStatus('error')
toast.error("Failed to connect MetaMask. Please try again.")
}
} catch (error: any) {
console.error('❌ Wallet connection error:', error)
setConnectionStatus('error')
if (error.message?.includes('User rejected')) {
toast.error("Connection cancelled by user.")
} else if (error.message?.includes('MetaMask not detected')) {
toast.error("Please install MetaMask extension.")
} else {
toast.error("MetaMask connection failed. Please try again.")
}
} finally {
setIsConnectingWallet(false)
}
@@ -107,17 +94,48 @@ export function LoginComponent() {
try {
console.log('📧 Attempting email login for:', email)
await loginWithEmail(email, password)
toast.success("Logged in successfully!")
router.push("/dashboard")
const success = await loginWithEmail(email, password)
if (success) {
setTimeout(() => router.push("/dashboard"), 500)
}
} catch (error: any) {
console.error('❌ Email login failed:', error)
toast.error(error.message || "Login failed. Please check your credentials.")
}
}
const { signupWithEmail } = useAuth()
const handleEmailSignup = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim() || !password.trim() || !username.trim()) {
toast.error("Please fill in all fields")
return
}
if (!email.includes('@')) {
toast.error("Please enter a valid email address")
return
}
if (password.length < 6) {
toast.error("Password must be at least 6 characters")
return
}
try {
console.log('📧 Attempting email signup for:', email)
const success = await signupWithEmail(email, password, username)
if (success) {
setTimeout(() => router.push("/dashboard"), 500)
}
} catch (error: any) {
console.error('❌ Email signup failed:', error)
}
}
// ✅ Show connected state if already authenticated
if (connectionStatus === 'connected' || (walletConnected && walletAddress)) {
if (user && authMethod) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
<Card className="w-full max-w-md shadow-2xl border-0 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm">
@@ -126,14 +144,18 @@ export function LoginComponent() {
<CheckCircle2 className="w-6 h-6 text-green-600" />
</div>
<CardTitle className="text-xl font-bold text-green-600">
MetaMask Connected! 🦊
{authMethod === 'metamask' ? 'MetaMask Connected' : 'Logged In'}
</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<Alert className="border-green-200 bg-green-50 dark:bg-green-900/20">
<Wallet className="w-4 h-4 text-green-600" />
<AlertDescription className="text-green-700 dark:text-green-300">
🦊 {walletAddress?.slice(0, 6)}...{walletAddress?.slice(-4)}
{authMethod === 'metamask' && walletAddress ? (
<>{walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}</>
) : (
<>{user.email}</>
)}
</AlertDescription>
</Alert>
@@ -146,7 +168,7 @@ export function LoginComponent() {
onClick={() => window.location.reload()}
className="w-full"
>
Connect Different Wallet
Logout
</Button>
</div>
</CardContent>
@@ -154,6 +176,12 @@ export function LoginComponent() {
</div>
)
}
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
@@ -165,7 +193,7 @@ export function LoginComponent() {
<div className="space-y-2">
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
Welcome to OpenLearnX! 🎓
Welcome to OpenLearnX
</CardTitle>
<p className="text-gray-600 dark:text-gray-300">
Connect your MetaMask wallet or login with email
@@ -194,14 +222,14 @@ export function LoginComponent() {
) : (
<>
<Wallet className="w-5 h-5 mr-2" />
Connect MetaMask Wallet 🦊
Connect MetaMask Wallet
</>
)}
</Button>
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
<p className="text-xs text-purple-700 dark:text-purple-300 text-center">
Recommended: Get Web3 features, blockchain verification, and token rewards!
Recommended: Get Web3 features, blockchain verification, and token rewards.
</p>
</div>
</div>
@@ -223,11 +251,47 @@ export function LoginComponent() {
className="w-full"
>
<Mail className="w-4 h-4 mr-2" />
{isEmailLogin ? 'Hide Email Login' : 'Login with Email'}
{isEmailLogin ? 'Hide Email Options' : 'Login with Email'}
</Button>
{isEmailLogin && (
<form onSubmit={handleEmailLogin} className="space-y-4 mt-4">
<div className="space-y-4 mt-4">
{/* Toggle between Login and Signup */}
<div className="flex gap-2 bg-gray-100 dark:bg-gray-700 p-1 rounded-lg">
<Button
variant={!isSignup ? "default" : "ghost"}
size="sm"
onClick={() => setIsSignup(false)}
className="flex-1"
>
Login
</Button>
<Button
variant={isSignup ? "default" : "ghost"}
size="sm"
onClick={() => setIsSignup(true)}
className="flex-1"
>
Sign Up
</Button>
</div>
<form onSubmit={isSignup ? handleEmailSignup : handleEmailLogin} className="space-y-4">
{isSignup && (
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
disabled={isLoadingAuth}
required={isSignup}
/>
</div>
)}
<div>
<Label htmlFor="email">Email Address</Label>
<Input
@@ -248,7 +312,7 @@ export function LoginComponent() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
placeholder={isSignup ? "Create a password (min 6 characters)" : "Enter your password"}
disabled={isLoadingAuth}
required
/>
@@ -256,22 +320,23 @@ export function LoginComponent() {
<Button
type="submit"
disabled={isLoadingAuth || !email.trim() || !password.trim()}
disabled={isLoadingAuth || !email.trim() || !password.trim() || (isSignup && !username.trim())}
className="w-full"
>
{isLoadingAuth ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in...
{isSignup ? 'Creating Account...' : 'Logging in...'}
</>
) : (
<>
<Lock className="w-4 h-4 mr-2" />
Login with Email
{isSignup ? 'Create Account' : 'Login'}
</>
)}
</Button>
</form>
</div>
)}
</div>
@@ -0,0 +1,49 @@
"use client"
import { useMemo } from "react"
import { usePathname } from "next/navigation"
import { ShieldAlert } from "lucide-react"
import { useAuth } from "@/context/auth-context"
const BLOCKED_STATUSES = new Set(["suspended", "restricted", "banned"])
export function AccountStatusGuard() {
const pathname = usePathname()
const { user, isLoadingAuth, logout } = useAuth()
const status = useMemo(() => String((user as any)?.status || "active").toLowerCase().trim(), [user])
const skipGuard = pathname.startsWith("/auth/") || pathname === "/admin/login"
if (skipGuard || isLoadingAuth || !user) return null
if (!BLOCKED_STATUSES.has(status)) return null
const title = status === "banned" ? "Account Banned" : "Account Suspended"
const message =
status === "banned"
? "Your account is banned. You cannot use OpenLearnX with this account. Contact admin for support."
: "Your account is suspended. Contact admin to restore access."
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white p-4 dark:bg-slate-950">
<div className="w-full max-w-xl rounded-2xl border-2 border-red-200 bg-white p-7 text-center shadow-xl dark:border-red-900 dark:bg-slate-900">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">
<ShieldAlert className="h-7 w-7" />
</div>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">{title}</h2>
<p className="mt-2 inline-flex rounded-full bg-red-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-red-700 dark:bg-red-950 dark:text-red-200">
Status: {status}
</p>
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300">{message}</p>
<p className="mt-2 text-sm font-medium text-red-700 dark:text-red-300">Contact admin.</p>
<div className="mt-5">
<button
onClick={() => logout()}
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Logout
</button>
</div>
</div>
</div>
)
}
+81 -22
View File
@@ -8,37 +8,56 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "react-hot-toast"
export function AuthButtons() {
const { user, firebaseUser, isLoadingAuth, authMethod, connectWallet, loginWithEmail, signupWithEmail, logout } =
useAuth()
const { user, isLoadingAuth, authMethod, connectWallet, loginWithEmail, signupWithEmail, logout } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [username, setUsername] = useState("")
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false)
const handleEmailLogin = async () => {
await loginWithEmail(email, password)
if (!email.trim() || !password.trim()) {
toast.error("Please enter email and password")
return
}
const success = await loginWithEmail(email, password)
if (success) {
setIsAuthModalOpen(false)
setEmail("")
setPassword("")
}
}
const handleEmailSignup = async () => {
await signupWithEmail(email, password)
if (!email.trim() || !password.trim() || !username.trim()) {
toast.error("Please fill in all fields")
return
}
if (password.length < 6) {
toast.error("Password must be at least 6 characters")
return
}
const success = await signupWithEmail(email, password, username)
if (success) {
setIsAuthModalOpen(false)
setEmail("")
setPassword("")
setUsername("")
}
}
const displayAddress = user?.wallet_address || firebaseUser?.email || "Guest"
const displayAddress = user?.wallet_address || user?.email || "Guest"
return (
<div className="flex items-center gap-4">
{authMethod ? (
{authMethod && user ? (
<>
<span className="text-sm text-gray-600 dark:text-gray-300">
Connected:{" "}
{authMethod === "metamask" && user?.wallet_address
{authMethod === "metamask" && user.wallet_address
? `${user.wallet_address.slice(0, 6)}...${user.wallet_address.slice(-4)}`
: authMethod === "firebase" && firebaseUser?.email
? firebaseUser.email
: displayAddress}
: user.email || displayAddress}
</span>
<Button onClick={logout} variant="outline" disabled={isLoadingAuth}>
Logout
@@ -76,12 +95,18 @@ export function AuthButtons() {
</TabsContent>
<TabsContent value="email" className="space-y-4 p-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Use email for a quick testing process (quizzes only).
Use email to access courses and quizzes.
</p>
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="login" className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="login-email">Email</Label>
<Input
id="email"
id="login-email"
type="email"
placeholder="your@email.com"
value={email}
@@ -90,34 +115,68 @@ export function AuthButtons() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="login-password">Password</Label>
<Input
id="password"
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleEmailLogin}
disabled={isLoadingAuth}
className="flex-1 bg-primary-purple hover:bg-primary-purple/90 text-white"
className="w-full bg-primary-purple hover:bg-primary-purple/90 text-white"
>
{isLoadingAuth && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Login
</Button>
</TabsContent>
<TabsContent value="signup" className="space-y-3">
<div className="space-y-2">
<Label htmlFor="signup-username">Username</Label>
<Input
id="signup-username"
type="text"
placeholder="Choose a username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="space-y-2">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
type="password"
placeholder="Min 6 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<Button
onClick={handleEmailSignup}
disabled={isLoadingAuth}
variant="outline"
className="flex-1 dark:text-gray-100 dark:border-gray-600 bg-transparent"
className="w-full bg-primary-purple hover:bg-primary-purple/90 text-white"
>
{isLoadingAuth && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign Up
Create Account
</Button>
</div>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</DialogContent>
+6 -6
View File
@@ -13,15 +13,15 @@ import { Loader2, CheckCircle2 } from "lucide-react"
import { api } from "@/lib/api" // Import api
export function CodingProblemList() {
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const { user, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const router = useRouter()
const [problems, setProblems] = useState<CodingProblem[]>([])
const [isLoadingProblems, setIsLoadingProblems] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isLoadingAuth && !user && !firebaseUser) {
// Allow either MetaMask or Firebase user
if (!isLoadingAuth && !user) {
// Allow MetaMask or email auth
toast.error("Please login to view coding problems.")
router.push("/")
return
@@ -44,11 +44,11 @@ export function CodingProblemList() {
}
}
if (user || firebaseUser) {
// Only fetch if either user type is logged in
if (user) {
// Only fetch if user is logged in
fetchProblems()
}
}, [user, firebaseUser, isLoadingAuth, router, token])
}, [user, isLoadingAuth, router, token])
const getDifficultyColor = (difficulty: CodingProblem["difficulty"]) => {
switch (difficulty) {
+6 -6
View File
@@ -17,7 +17,7 @@ interface CodingProblemViewProps {
}
export function CodingProblemView({ problemId }: CodingProblemViewProps) {
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const { user, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const router = useRouter()
const [problem, setProblem] = useState<CodingProblem | null>(null)
const [code, setCode] = useState<string>("")
@@ -31,8 +31,8 @@ export function CodingProblemView({ problemId }: CodingProblemViewProps) {
const availableLanguages = ["python", "javascript", "java"] // Example languages
useEffect(() => {
if (!isLoadingAuth && !user && !firebaseUser) {
// Allow either MetaMask or Firebase user
if (!isLoadingAuth && !user) {
// Allow either MetaMask or email auth
toast.error("Please login to view coding problems.")
router.push("/")
return
@@ -64,11 +64,11 @@ export function CodingProblemView({ problemId }: CodingProblemViewProps) {
}
}
if (user || firebaseUser) {
// Only fetch if either user type is logged in
if (user) {
// Only fetch if user is logged in
fetchProblem()
}
}, [user, firebaseUser, isLoadingAuth, router, problemId, language, token])
}, [user, isLoadingAuth, router, problemId, language, token])
const handleRunCode = async () => {
if (!problem || !code || !token) {
+7 -7
View File
@@ -13,15 +13,15 @@ import { Loader2 } from "lucide-react"
import api from "@/lib/api" // Corrected import: default import
export function CourseList() {
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const { user, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const router = useRouter()
const [courses, setCourses] = useState<Course[]>([])
const [isLoadingCourses, setIsLoadingCourses] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isLoadingAuth && !user && !firebaseUser) {
// Allow either MetaMask or Firebase user
if (!isLoadingAuth && !user) {
// Allow either MetaMask or email auth
toast.error("Please login to view courses.")
router.push("/")
return
@@ -49,11 +49,11 @@ export function CourseList() {
}
}
if (user || firebaseUser) {
// Fetch if either user type is logged in
if (user) {
// Fetch if user is logged in
fetchCourses()
}
}, [user, firebaseUser, isLoadingAuth, router, token])
}, [user, isLoadingAuth, router, token])
if (isLoadingAuth || isLoadingCourses) {
return (
@@ -97,7 +97,7 @@ export function CourseList() {
{courses.map((course) => (
<Card
key={course.id}
className="bg-white shadow-md rounded-lg overflow-hidden dark:bg-gray-800 dark:text-gray-100"
className="bg-white shadow-md rounded-lg overflow-hidden dark:bg-[#22314a] dark:text-gray-100 dark:border-blue-300/20"
>
<CardHeader>
<CardTitle className="text-xl font-semibold">{course.title}</CardTitle>
+13 -8
View File
@@ -69,6 +69,7 @@ interface ActivityData {
title: string
description: string
completed_at: string
timestamp_utc?: string
points_earned: number
blockchain_verified?: boolean
}
@@ -185,14 +186,18 @@ export function DashboardStatsOverview() {
fetchPureMongoDBData()
}
const formatTimeAgo = (dateString: string) => {
const diff = Date.now() - new Date(dateString).getTime()
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(hours / 24)
const formatUtcTimestamp = (dateString: string) => {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return "Invalid time"
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
return 'Just now'
const y = date.getUTCFullYear()
const m = String(date.getUTCMonth() + 1).padStart(2, "0")
const d = String(date.getUTCDate()).padStart(2, "0")
const hh = String(date.getUTCHours()).padStart(2, "0")
const mm = String(date.getUTCMinutes()).padStart(2, "0")
const ss = String(date.getUTCSeconds()).padStart(2, "0")
return `${y}-${m}-${d} ${hh}:${mm}:${ss} UTC`
}
const getRarityColor = (rarity: string) => {
@@ -566,7 +571,7 @@ export function DashboardStatsOverview() {
<p className="text-xs text-gray-600 dark:text-gray-400">{item.description}</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{formatTimeAgo(item.completed_at)}
{item.timestamp_utc || formatUtcTimestamp(item.completed_at)}
</span>
<span className="text-xs font-medium text-green-600">
+{item.points_earned} XP
+4 -4
View File
@@ -30,7 +30,7 @@ interface LessonData {
}
export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth()
const { user, isLoadingAuth, authMethod, token } = useAuth()
const router = useRouter()
const [lesson, setLesson] = useState<LessonData | null>(null)
const [isLoadingLesson, setIsLoadingLesson] = useState(true)
@@ -38,7 +38,7 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isLoadingAuth && !user && !firebaseUser) {
if (!isLoadingAuth && !user) {
toast.error("Please login to view lessons.")
router.push("/")
return
@@ -76,10 +76,10 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
}
}
if (user || firebaseUser) {
if (user) {
fetchLesson()
}
}, [user, firebaseUser, isLoadingAuth, router, courseId, lessonId, token])
}, [user, isLoadingAuth, router, courseId, lessonId, token])
const markLessonCompleted = async () => {
if (!lesson || lesson.completed || !token) {
@@ -0,0 +1,161 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Loader2, X } from "lucide-react"
import { toast } from "react-hot-toast"
import api from "@/lib/api"
interface MetaMaskEmailModalProps {
isOpen: boolean
walletAddress: string
token: string
onSuccess: (user: any) => void
onCancel: () => void
}
export function MetaMaskEmailModal({
isOpen,
walletAddress,
token,
onSuccess,
onCancel,
}: MetaMaskEmailModalProps) {
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) {
toast.error("Please enter your email address")
return
}
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)) {
toast.error("Please enter a valid email address")
return
}
setIsSubmitting(true)
try {
const response = await api.post(
"/api/auth/metamask/add-email",
{
email: email.toLowerCase(),
name: name.trim(),
},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
if (response.data.success && response.data.user) {
toast.success("Email saved successfully!")
onSuccess(response.data.user)
} else {
toast.error(response.data.error || "Failed to save email")
}
} catch (error: any) {
console.error("Error saving email:", error)
toast.error(
error.response?.data?.error || "Failed to save email address"
)
} finally {
setIsSubmitting(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xl font-bold">Save Contact Email</CardTitle>
<button
onClick={onCancel}
className="p-1 hover:bg-gray-100 rounded-lg transition"
disabled={isSubmitting}
>
<X className="w-5 h-5 text-gray-500" />
</button>
</CardHeader>
<CardContent>
<div className="space-y-4 mb-6">
<p className="text-sm text-gray-600">
Connected wallet: <span className="font-mono font-semibold">{walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}</span>
</p>
<p className="text-sm text-gray-600">
Add your contact email and name to complete your profile setup.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Display Name (optional)</Label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
disabled={isSubmitting}
/>
</div>
<div>
<Label htmlFor="email">Contact Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email address"
disabled={isSubmitting}
required
/>
<p className="text-xs text-gray-500 mt-1">
We will use this to verify your account and send important updates
</p>
</div>
<div className="flex gap-3 pt-4">
<Button
type="submit"
disabled={isSubmitting || !email.trim()}
className="flex-1 bg-purple-600 hover:bg-purple-700"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Email"
)}
</Button>
<Button
type="button"
onClick={onCancel}
disabled={isSubmitting}
variant="outline"
className="flex-1"
>
Skip for Now
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+6 -6
View File
@@ -15,15 +15,15 @@ import { Loader2 } from "lucide-react"
import api from "@/lib/api" // Import api
export function QuizList() {
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const { user, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
const router = useRouter()
const [quizzes, setQuizzes] = useState<Quiz[]>([])
const [isLoadingQuizzes, setIsLoadingQuizzes] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isLoadingAuth && !user && !firebaseUser) {
// Allow either MetaMask or Firebase user
if (!isLoadingAuth && !user) {
// Allow MetaMask or email auth
toast.error("Please login to view quizzes.")
router.push("/")
return
@@ -46,11 +46,11 @@ export function QuizList() {
}
}
if (user || firebaseUser) {
// Fetch if either user type is logged in
if (user) {
// Fetch if user is logged in
fetchQuizzes()
}
}, [user, firebaseUser, isLoadingAuth, router, token])
}, [user, isLoadingAuth, router, token])
const getDifficultyColor = (difficulty: Quiz["difficulty"]) => {
switch (difficulty) {
+10 -15
View File
@@ -20,7 +20,7 @@ import type { TestStartRequest, TestStartResponse, TestAnswerRequest, TestAnswer
type QuizState = "subject_selection" | "in_progress" | "showing_feedback" | "completed"
export function QuizRunner({ quizId }: { quizId?: string }) {
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Use firebaseUser and authMethod
const { user, isLoadingAuth, authMethod, token } = useAuth() // Use authMethod and token
const router = useRouter()
const [quizState, setQuizState] = useState<QuizState>("subject_selection")
@@ -36,11 +36,11 @@ export function QuizRunner({ quizId }: { quizId?: string }) {
const availableSubjects = ["Math", "Science", "History", "Literature"] // Example subjects
useEffect(() => {
if (!isLoadingAuth && !user && !firebaseUser) {
if (!isLoadingAuth && !user) {
toast.error("Please login to take a quiz.")
router.push("/") // Redirect to home if not authenticated
}
}, [user, firebaseUser, isLoadingAuth, router])
}, [user, isLoadingAuth, router])
const startQuiz = async () => {
if (!selectedSubject) {
@@ -65,9 +65,9 @@ export function QuizRunner({ quizId }: { quizId?: string }) {
/*
// --- ORIGINAL API CALL (UNCOMMENT WHEN BACKEND IS READY) ---
*/
if (authMethod === "firebase" && !token) {
toast.error("Quiz progress and persistence require MetaMask authentication.")
return // Prevent API call for Firebase users without JWT
if (!token) {
toast.error("Authentication token missing. Please log in again.")
return // Prevent API call without JWT
}
setIsStartingQuiz(true)
@@ -130,9 +130,9 @@ export function QuizRunner({ quizId }: { quizId?: string }) {
/*
// --- ORIGINAL API CALL (UNCOMMENT WHEN BACKEND IS READY) ---
*/
if (authMethod === "firebase" && !token) {
toast.error("Quiz progress and persistence require MetaMask authentication.")
return // Prevent API call for Firebase users without JWT
if (!token) {
toast.error("Authentication token missing. Please log in again.")
return // Prevent API call without JWT
}
setIsSubmittingAnswer(true)
@@ -182,7 +182,7 @@ export function QuizRunner({ quizId }: { quizId?: string }) {
setFeedback(null)
}
if (isLoadingAuth || (!user && !firebaseUser)) {
if (isLoadingAuth || !user) {
return (
<div className="flex justify-center items-center min-h-[calc(100vh-64px)]">
<Loader2 className="h-8 w-8 animate-spin text-primary-purple" />
@@ -220,11 +220,6 @@ export function QuizRunner({ quizId }: { quizId?: string }) {
{isStartingQuiz && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Start Quiz
</Button>
{authMethod === "firebase" && !token && (
<p className="text-sm text-center text-warning dark:text-warning/80 mt-4">
Note: Quiz progress will not be saved without MetaMask authentication.
</p>
)}
</CardContent>
</Card>
)}
+1 -1
View File
@@ -29,7 +29,7 @@ function Calendar({
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
+16 -8
View File
@@ -8,18 +8,26 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Menu, Sun, Moon } from "lucide-react"
import { useTheme } from "next-themes"
import { useState, useEffect } from "react"
import { usePathname } from "next/navigation"
export function Navbar() {
const { user, firebaseUser, authMethod } = useAuth() // Use authMethod to determine display
const { theme, setTheme } = useTheme()
const { user, authMethod } = useAuth() // Use authMethod to determine display
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const pathname = usePathname()
useEffect(() => {
setMounted(true)
}, [])
if (pathname.startsWith("/admin")) {
return null
}
const isDark = resolvedTheme === "dark"
return (
<header className="sticky top-0 z-40 w-full border-b bg-white/80 backdrop-blur-md dark:bg-gray-950/80">
<header className="sticky top-0 z-40 w-full border-b bg-white/80 backdrop-blur-md dark:bg-[#102a52]/90">
<div className="container flex h-16 items-center justify-between">
<Link href="/" className="text-2xl font-bold text-primary-purple">
OpenLearnX
@@ -59,10 +67,10 @@ export function Navbar() {
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
onClick={() => setTheme(isDark ? "light" : "dark")}
className="ml-2"
>
{mounted && (theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />)}
{mounted && (isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />)}
{!mounted && <div className="h-5 w-5" />} {/* Render a placeholder div to maintain layout */}
<span className="sr-only">Toggle theme</span>
</Button>
@@ -71,10 +79,10 @@ export function Navbar() {
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
onClick={() => setTheme(isDark ? "light" : "dark")}
className="mr-2"
>
{mounted && (theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />)}
{mounted && (isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />)}
{!mounted && <div className="h-5 w-5" />} {/* Render a placeholder div to maintain layout */}
<span className="sr-only">Toggle theme</span>
</Button>
@@ -85,7 +93,7 @@ export function Navbar() {
<span className="sr-only">Toggle navigation</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[250px] sm:w-[300px] p-4 dark:bg-gray-900">
<SheetContent side="right" className="w-[250px] sm:w-[300px] p-4 dark:bg-[#1b3760]">
<nav className="flex flex-col gap-4">
<Link
href="/"
+186 -127
View File
@@ -4,214 +4,274 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
import detectEthereumProvider from "@metamask/detect-provider"
import { ethers } from "ethers"
import { toast } from "react-hot-toast"
import api from "@/lib/api"
import { auth } from "@/lib/firebase"
import {
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut,
onAuthStateChanged,
type User as FirebaseUser,
} from "firebase/auth"
interface User {
id: string
wallet_address: string
name?: string
bio?: string
avatar?: string
created_at: string
last_login: string
}
import authService, { type User } from "@/lib/auth-service"
interface AuthContextType {
user: User | null
firebaseUser: FirebaseUser | null
token: string | null
isLoadingAuth: boolean
authMethod: "metamask" | "firebase" | null
authMethod: "metamask" | "email" | null
walletAddress: string | null
walletConnected: boolean
connectWallet: () => Promise<void>
loginWithEmail: (email: string, password: string) => Promise<void>
signupWithEmail: (email: string, password: string) => Promise<void>
logout: () => void
showMetaMaskEmailModal: boolean
setShowMetaMaskEmailModal: (show: boolean) => void
connectWallet: () => Promise<boolean>
loginWithEmail: (email: string, password: string) => Promise<boolean>
signupWithEmail: (email: string, password: string, username?: string) => Promise<boolean>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoadingAuth, setIsLoadingAuth] = useState(true)
const [authMethod, setAuthMethod] = useState<"metamask" | "firebase" | null>(null)
const [authMethod, setAuthMethod] = useState<"metamask" | "email" | null>(null)
const [walletAddress, setWalletAddress] = useState<string | null>(null)
const [walletConnected, setWalletConnected] = useState(false)
const [showMetaMaskEmailModal, setShowMetaMaskEmailModal] = useState(false)
// Initialize auth state
// Initialize auth state from localStorage
useEffect(() => {
const initializeAuth = async () => {
try {
const storedToken = localStorage.getItem("openlearnx_jwt_token")
const storedUser = localStorage.getItem("openlearnx_user")
const storedWallet = localStorage.getItem("openlearnx_wallet")
const storedMethod = localStorage.getItem("openlearnx_auth_method") as "metamask" | "email" | null
if (storedToken && storedUser && storedWallet) {
try {
setUser(JSON.parse(storedUser))
if (storedToken) {
// Verify token is still valid
const verification = await authService.verifyToken(storedToken)
if (verification.valid && verification.user) {
setToken(storedToken)
setUser(verification.user)
setAuthMethod(storedMethod || "email")
// If MetaMask, restore wallet address
if (storedMethod === "metamask") {
const storedWallet = localStorage.getItem("openlearnx_wallet")
if (storedWallet) {
setWalletAddress(storedWallet)
setWalletConnected(true)
setAuthMethod("metamask")
} catch (error) {
localStorage.clear()
}
}
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
if (currentUser && authMethod !== "metamask") {
setFirebaseUser(currentUser)
setAuthMethod("firebase")
} else if (!currentUser && authMethod === "firebase") {
setFirebaseUser(null)
} else {
// Token expired or invalid
authService.clearToken()
setToken(null)
setUser(null)
setAuthMethod(null)
}
}
} catch (error) {
console.error("Auth initialization error:", error)
authService.clearToken()
} finally {
setIsLoadingAuth(false)
})
}
}
return () => unsubscribe()
}, [authMethod])
initializeAuth()
}, [])
const connectWallet = useCallback(async () => {
/**
* Connect MetaMask wallet
*/
const connectWallet = useCallback(async (): Promise<boolean> => {
setIsLoadingAuth(true)
try {
const provider = await detectEthereumProvider()
if (!provider) {
toast.error("MetaMask not detected. Please install it.")
return
return false
}
// Create ethers provider from MetaMask
const ethProvider = new ethers.BrowserProvider(provider as any)
// Request accounts
const accounts = await ethProvider.send("eth_requestAccounts", [])
if (accounts.length === 0) {
toast.error("No accounts connected.")
return
toast.error("No MetaMask accounts found.")
return false
}
const walletAddr = accounts[0]
const walletAddr = accounts[0].toLowerCase()
// Get nonce from backend
const nonceResponse = await api.post("/api/auth/nonce", {
wallet_address: walletAddr,
})
if (!nonceResponse.data.success) {
throw new Error(nonceResponse.data.error || "Failed to get nonce")
const nonceResponse = await authService.getNonce(walletAddr)
if (!nonceResponse.success || !nonceResponse.message) {
toast.error(nonceResponse.error || "Failed to get authentication nonce")
return false
}
const { message } = nonceResponse.data
// Sign message
// Sign message with MetaMask
const signer = await ethProvider.getSigner()
const signature = await signer.signMessage(message)
let signature: string
// Verify signature
const verifyResponse = await api.post("/api/auth/verify", {
wallet_address: walletAddr,
signature,
message,
})
try {
signature = await signer.signMessage(nonceResponse.message)
} catch (signError: any) {
if (signError.message?.includes("user rejected")) {
toast.error("You rejected the signature request")
} else {
toast.error("Failed to sign message")
}
return false
}
if (verifyResponse.data.success) {
const { token, user } = verifyResponse.data
// Verify signature with backend
const verifyResponse = await authService.verifySignature(walletAddr, signature, nonceResponse.message)
// Update states
setToken(token)
setUser(user)
if (!verifyResponse.success || !verifyResponse.token || !verifyResponse.user) {
toast.error(verifyResponse.error || "Authentication failed")
return false
}
// Update state
const { token: newToken, user: newUser } = verifyResponse
setToken(newToken)
setUser(newUser)
setWalletAddress(walletAddr)
setWalletConnected(true)
setFirebaseUser(null)
setAuthMethod("metamask")
// Store in localStorage
localStorage.setItem("openlearnx_jwt_token", token)
localStorage.setItem("openlearnx_user", JSON.stringify(user))
authService.setToken(newToken)
localStorage.setItem("openlearnx_user", JSON.stringify(newUser))
localStorage.setItem("openlearnx_wallet", walletAddr)
localStorage.setItem("openlearnx_auth_method", "metamask")
toast.success(`Welcome! 🦊`)
toast.success("Connected to MetaMask! Now add your contact email")
// ✅ CRITICAL: Redirect to dashboard after successful login
setTimeout(() => {
window.location.href = "/dashboard"
}, 1000)
} else {
throw new Error("Authentication failed")
}
// Show email modal for contact information
setShowMetaMaskEmailModal(true)
return true
} catch (error: any) {
console.error("MetaMask error:", error)
toast.error(error.message || "Failed to connect MetaMask")
console.error("MetaMask connection error:", error)
toast.error("Failed to connect MetaMask")
return false
} finally {
setIsLoadingAuth(false)
}
}, [])
const loginWithEmail = useCallback(async (email: string, password: string) => {
/**
* Login with email and password
*/
const loginWithEmail = useCallback(async (email: string, password: string): Promise<boolean> => {
setIsLoadingAuth(true)
try {
await signInWithEmailAndPassword(auth, email, password)
const response = await authService.login(email, password)
if (!response.success || !response.token || !response.user) {
toast.error(response.error || "Login failed")
return false
}
// Update state
const { token: newToken, user: newUser } = response
setToken(newToken)
setUser(newUser)
setAuthMethod("email")
setWalletConnected(false)
setWalletAddress(null)
// Store in localStorage
authService.setToken(newToken)
localStorage.setItem("openlearnx_user", JSON.stringify(newUser))
localStorage.setItem("openlearnx_auth_method", "email")
toast.success("Logged in successfully")
return true
} catch (error: any) {
console.error("Email login error:", error)
toast.error("Login failed. Please try again.")
return false
} finally {
setIsLoadingAuth(false)
}
}, [])
/**
* Signup with email and password
*/
const signupWithEmail = useCallback(
async (email: string, password: string, username?: string): Promise<boolean> => {
setIsLoadingAuth(true)
try {
const response = await authService.signup(email, password, username)
if (!response.success || !response.token || !response.user) {
toast.error(response.error || "Signup failed")
return false
}
// Update state
const { token: newToken, user: newUser } = response
setToken(newToken)
setUser(newUser)
setAuthMethod("email")
setWalletConnected(false)
setWalletAddress(null)
// Store in localStorage
authService.setToken(newToken)
localStorage.setItem("openlearnx_user", JSON.stringify(newUser))
localStorage.setItem("openlearnx_auth_method", "email")
toast.success("Account created successfully")
return true
} catch (error: any) {
console.error("Email signup error:", error)
toast.error("Signup failed. Please try again.")
return false
} finally {
setIsLoadingAuth(false)
}
},
[]
)
/**
* Logout user
*/
const logout = useCallback(async (): Promise<void> => {
try {
await authService.logout()
// Clear state
setUser(null)
setToken(null)
setWalletAddress(null)
setWalletConnected(false)
toast.success("Logged in with email!")
} catch (error: any) {
toast.error(error.message || "Email login failed")
throw error
} finally {
setIsLoadingAuth(false)
}
}, [])
const signupWithEmail = useCallback(async (email: string, password: string) => {
setIsLoadingAuth(true)
try {
await createUserWithEmailAndPassword(auth, email, password)
toast.success("Account created!")
} catch (error: any) {
toast.error(error.message || "Signup failed")
throw error
} finally {
setIsLoadingAuth(false)
}
}, [])
const logout = useCallback(async () => {
setUser(null)
setFirebaseUser(null)
setToken(null)
setWalletAddress(null)
setWalletConnected(false)
setAuthMethod(null)
localStorage.clear()
setWalletAddress(null)
setWalletConnected(false)
try {
await signOut(auth)
// Clear storage
authService.clearToken()
localStorage.removeItem("openlearnx_auth_method")
toast.success("Logged out successfully!")
} catch (error) {
console.error("Logout error:", error)
toast.error("Logout failed")
}
toast.success("Logged out!")
}, [])
const value = {
const value: AuthContextType = {
user,
firebaseUser,
token,
isLoadingAuth,
authMethod,
walletAddress,
walletConnected,
showMetaMaskEmailModal,
setShowMetaMaskEmailModal,
connectWallet,
loginWithEmail,
signupWithEmail,
@@ -221,13 +281,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (!context) {
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}
// ✅ CRITICAL: Default export to fix the "invalid element type" error
export default AuthProvider
+28
View File
@@ -2,11 +2,14 @@ import axios from "axios"
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:5000"
console.log("🌐 API Base URL:", API_BASE_URL)
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
timeout: 10000, // 10 second timeout
})
api.interceptors.request.use(
@@ -15,9 +18,34 @@ api.interceptors.request.use(
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
console.log("📤 API Request:", {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
})
return config
},
(error) => {
console.error("❌ Request interceptor error:", error)
return Promise.reject(error)
},
)
api.interceptors.response.use(
(response) => {
console.log("📥 API Response:", {
status: response.status,
url: response.config.url,
})
return response
},
(error) => {
console.error("❌ API Response error:", {
status: error.response?.status,
url: error.config?.url,
message: error.message,
code: error.code,
})
return Promise.reject(error)
},
)
+224
View File
@@ -0,0 +1,224 @@
/**
* MongoDB-based Authentication Service
* Replaces Firebase with backend API calls to MongoDB
*/
import api from "./api"
export interface User {
id: string
email: string
username?: string
wallet_address?: string
role?: string
status?: string
name?: string
bio?: string
avatar?: string
created_at: string
last_login: string
}
export interface AuthResponse {
success: boolean
message?: string
token?: string
user?: User
error?: string
}
class AuthService {
/**
* Register a new user with email and password
*/
async signup(email: string, password: string, username?: string): Promise<AuthResponse> {
try {
console.log("🔐 Signup request to:", api.defaults.baseURL + "/api/auth/register")
const response = await api.post("/api/auth/register", {
email,
password,
username: username || email.split("@")[0],
})
console.log("✅ Signup successful:", response.data)
return response.data
} catch (error: any) {
console.error("❌ Signup error details:", {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: error.config?.url,
baseURL: error.config?.baseURL,
})
return {
success: false,
error: error.response?.data?.error || error.message || "Network error - unable to connect to server",
}
}
}
/**
* Login with email and password
*/
async login(email: string, password: string): Promise<AuthResponse> {
try {
console.log("🔐 Login request to:", api.defaults.baseURL + "/api/auth/login")
const response = await api.post("/api/auth/login", {
email,
password,
})
console.log("✅ Login successful:", response.data)
return response.data
} catch (error: any) {
console.error("❌ Login error details:", {
message: error.message,
status: error.response?.status,
data: error.response?.data,
})
return {
success: false,
error: error.response?.data?.error || error.message || "Network error - unable to connect to server",
}
}
}
/**
* Get nonce for wallet signature
*/
async getNonce(walletAddress: string): Promise<{ success: boolean; message?: string; error?: string }> {
try {
const response = await api.post("/api/auth/nonce", {
wallet_address: walletAddress,
})
return response.data
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || "Failed to get nonce",
}
}
}
/**
* Verify wallet signature for authentication
*/
async verifySignature(
walletAddress: string,
signature: string,
message: string
): Promise<AuthResponse> {
try {
const response = await api.post("/api/auth/verify", {
wallet_address: walletAddress,
signature,
message,
})
return response.data
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || "Verification failed",
}
}
}
/**
* Verify JWT token
*/
async verifyToken(token: string): Promise<{ valid: boolean; user?: User }> {
try {
const response = await api.post(
"/api/auth/verify-token",
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
return response.data
} catch (error: any) {
return {
valid: false,
}
}
}
/**
* Logout user
*/
async logout(): Promise<void> {
try {
await api.post("/api/auth/logout")
} catch (error) {
console.error("Logout error:", error)
}
}
/**
* Get current user info
*/
async getCurrentUser(token: string): Promise<User | null> {
try {
const response = await api.get("/api/auth/me", {
headers: {
Authorization: `Bearer ${token}`,
},
})
return response.data.user || null
} catch (error) {
return null
}
}
/**
* Update user profile
*/
async updateProfile(
token: string,
data: Partial<User>
): Promise<{ success: boolean; user?: User; error?: string }> {
try {
const response = await api.put("/api/auth/profile", data, {
headers: {
Authorization: `Bearer ${token}`,
},
})
return response.data
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || "Update failed",
}
}
}
/**
* Store token in localStorage
*/
setToken(token: string): void {
localStorage.setItem("openlearnx_jwt_token", token)
api.defaults.headers.common["Authorization"] = `Bearer ${token}`
}
/**
* Get stored token
*/
getToken(): string | null {
return localStorage.getItem("openlearnx_jwt_token")
}
/**
* Clear stored token
*/
clearToken(): void {
localStorage.removeItem("openlearnx_jwt_token")
localStorage.removeItem("openlearnx_user")
localStorage.removeItem("openlearnx_wallet")
delete api.defaults.headers.common["Authorization"]
}
}
export const authService = new AuthService()
export default authService
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+84 -4
View File
@@ -28,8 +28,8 @@ const config: Config = {
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
blue: "#2563eb", // Primary blue
purple: "#7c3aed", // Primary purple
blue: "#2563eb",
purple: "#7c3aed",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
@@ -56,10 +56,10 @@ const config: Config = {
foreground: "hsl(var(--card-foreground))",
},
success: {
DEFAULT: "#059669", // Success green
DEFAULT: "#059669",
},
warning: {
DEFAULT: "#f59e0b", // Warning orange
DEFAULT: "#f59e0b",
},
},
borderRadius: {
@@ -67,6 +67,20 @@ const config: Config = {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
transitionDelay: {
"0": "0ms",
"100": "100ms",
"200": "200ms",
"300": "300ms",
"500": "500ms",
"700": "700ms",
"800": "800ms",
"1000": "1000ms",
"1200": "1200ms",
"1600": "1600ms",
"2000": "2000ms",
"4000": "4000ms",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
@@ -76,10 +90,76 @@ const config: Config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"blob": {
"0%, 100%": { transform: "translate(0, 0) scale(1)" },
"33%": { transform: "translate(30px, -50px) scale(1.1)" },
"66%": { transform: "translate(-20px, 20px) scale(0.9)" },
},
"float": {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-20px)" },
},
"wiggle": {
"0%, 100%": { transform: "rotate(0deg)" },
"25%": { transform: "rotate(-1deg)" },
"75%": { transform: "rotate(1deg)" },
},
"rotate-slow": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
"spin-slow": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
"pulse-subtle": {
"0%, 100%": { opacity: "0.7" },
"50%": { opacity: "1" },
},
"morph": {
"0%, 100%": { borderRadius: "60% 40% 30% 70% / 60% 30% 70% 40%" },
"50%": { borderRadius: "30% 60% 70% 40% / 50% 60% 30% 60%" },
},
"morph-reverse": {
"0%, 100%": { borderRadius: "30% 60% 70% 40% / 50% 60% 30% 60%" },
"50%": { borderRadius: "60% 40% 30% 70% / 60% 30% 70% 40%" },
},
"wobble": {
"0%, 100%": { transform: "translateX(0%)" },
"15%": { transform: "translateX(-5px) rotate(-5deg)" },
"30%": { transform: "translateX(5px) rotate(3deg)" },
"45%": { transform: "translateX(-5px) rotate(-3deg)" },
"60%": { transform: "translateX(2px) rotate(2deg)" },
"75%": { transform: "translateX(-2px) rotate(-1deg)" },
},
"slide-in-up": {
"0%": { opacity: "0", transform: "translateY(30px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
"glow": {
"0%, 100%": { textShadow: "0 0 10px rgba(255, 255, 255, 0.5)" },
"50%": { textShadow: "0 0 20px rgba(255, 255, 255, 0.8)" },
},
"shimmer": {
"0%": { backgroundPosition: "-1000px 0" },
"100%": { backgroundPosition: "1000px 0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"blob": "blob 7s infinite",
"float": "float 3s ease-in-out infinite",
"wiggle": "wiggle 0.7s ease-in-out infinite",
"rotate-slow": "rotate-slow 20s linear infinite",
"spin-slow": "spin-slow 3s linear infinite",
"pulse-subtle": "pulse-subtle 2s ease-in-out infinite",
"morph": "morph 8s ease-in-out infinite",
"morph-reverse": "morph-reverse 8s ease-in-out infinite",
"wobble": "wobble 0.8s ease-in-out infinite",
"slide-in-up": "slide-in-up 0.6s ease-out",
"glow": "glow 2s ease-in-out infinite",
"shimmer": "shimmer 2s linear infinite",
},
},
},
+37 -11
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -12,7 +16,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -21,15 +25,37 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["./components/*"],
"@/hooks/*": ["./hooks/*"],
"@/lib/*": ["./lib/*"],
"@/utils/*": ["./utils/*"],
"@/types/*": ["./types/*"],
"@/app/*": ["./app/*"]
"@/*": [
"./*"
],
"@/components/*": [
"./components/*"
],
"@/hooks/*": [
"./hooks/*"
],
"@/lib/*": [
"./lib/*"
],
"@/utils/*": [
"./utils/*"
],
"@/types/*": [
"./types/*"
],
"@/app/*": [
"./app/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}