mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 11:25:49 +00:00
Course admin panel added
This commit is contained in:
+103
-9
@@ -1,22 +1,32 @@
|
|||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify, request
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
from mongo_service import MongoService
|
from mongo_service import MongoService
|
||||||
from web3_service import Web3Service
|
from web3_service import Web3Service
|
||||||
|
import logging
|
||||||
|
|
||||||
# Import all route blueprints
|
# Import all route blueprints
|
||||||
from routes import auth, test_flow, certificate, dashboard , courses, quizzes
|
from routes import auth, test_flow, certificate, dashboard, courses, quizzes, admin
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
|
||||||
|
# Enhanced CORS configuration for admin panel with credentials support
|
||||||
|
CORS(app, resources={
|
||||||
|
r"/api/*": {
|
||||||
|
"origins": ["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||||
|
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
"allow_headers": ["Content-Type", "Authorization"],
|
||||||
|
"supports_credentials": True # ✅ Added for admin authentication
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key')
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key')
|
||||||
app.config['MONGODB_URI'] = os.getenv('MONGODB_URI')
|
app.config['MONGODB_URI'] = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
|
||||||
app.config['WEB3_PROVIDER_URL'] = os.getenv('WEB3_PROVIDER_URL', 'http://127.0.0.1:8545')
|
app.config['WEB3_PROVIDER_URL'] = os.getenv('WEB3_PROVIDER_URL', 'http://127.0.0.1:8545')
|
||||||
app.config['CONTRACT_ADDRESS'] = os.getenv('CONTRACT_ADDRESS')
|
app.config['CONTRACT_ADDRESS'] = os.getenv('CONTRACT_ADDRESS')
|
||||||
app.config['MINTER_PRIVATE_KEY'] = os.getenv('MINTER_PRIVATE_KEY')
|
app.config['MINTER_PRIVATE_KEY'] = os.getenv('MINTER_PRIVATE_KEY')
|
||||||
@@ -36,19 +46,103 @@ app.register_blueprint(certificate.bp, url_prefix='/api/certificate')
|
|||||||
app.register_blueprint(dashboard.bp, url_prefix='/api/dashboard')
|
app.register_blueprint(dashboard.bp, url_prefix='/api/dashboard')
|
||||||
app.register_blueprint(courses.bp, url_prefix='/api/courses')
|
app.register_blueprint(courses.bp, url_prefix='/api/courses')
|
||||||
app.register_blueprint(quizzes.bp, url_prefix='/api/quizzes')
|
app.register_blueprint(quizzes.bp, url_prefix='/api/quizzes')
|
||||||
|
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def health_check():
|
def health_check():
|
||||||
return jsonify({"status": "OpenLearnX API is running", "version": "1.0.0"})
|
return jsonify({
|
||||||
|
"status": "OpenLearnX API is running",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"endpoints": {
|
||||||
|
"auth": "/api/auth",
|
||||||
|
"courses": "/api/courses",
|
||||||
|
"admin": "/api/admin",
|
||||||
|
"dashboard": "/api/dashboard",
|
||||||
|
"certificates": "/api/certificate",
|
||||||
|
"quizzes": "/api/quizzes"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/admin/health')
|
||||||
|
def admin_health():
|
||||||
|
return jsonify({
|
||||||
|
"status": "Admin API is running",
|
||||||
|
"admin_endpoints": [
|
||||||
|
"/api/admin/dashboard",
|
||||||
|
"/api/admin/courses",
|
||||||
|
"/api/admin/courses/<id>",
|
||||||
|
"/api/admin/test" # ✅ Added test endpoint
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error):
|
||||||
|
return jsonify({"error": "Endpoint not found"}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
app.logger.error(f"Internal server error: {str(error)}")
|
||||||
|
return jsonify({"error": "Internal server error"}), 500
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def handle_error(error):
|
def handle_error(error):
|
||||||
app.logger.error(f"Error: {str(error)}")
|
app.logger.error(f"Unhandled error: {str(error)}")
|
||||||
return jsonify({"error": "Internal server error"}), 500
|
return jsonify({"error": "An unexpected error occurred"}), 500
|
||||||
|
|
||||||
|
# Enable logging for admin operations
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' # ✅ Enhanced logging format
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def log_request_info():
|
||||||
|
if '/api/admin' in request.path:
|
||||||
|
# ✅ Enhanced admin request logging
|
||||||
|
auth_header = request.headers.get('Authorization', 'No auth header')
|
||||||
|
logger.info(f"Admin request: {request.method} {request.path} | Auth: {auth_header}")
|
||||||
|
|
||||||
|
# ✅ Add OPTIONS handler for CORS preflight
|
||||||
|
@app.before_request
|
||||||
|
def handle_preflight():
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
response = jsonify()
|
||||||
|
response.headers.add("Access-Control-Allow-Origin", "*")
|
||||||
|
response.headers.add('Access-Control-Allow-Headers', "*")
|
||||||
|
response.headers.add('Access-Control-Allow-Methods', "*")
|
||||||
|
return response
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Initialize database
|
try:
|
||||||
|
# ✅ Enhanced database initialization with better error handling
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.run_until_complete(mongo_service.init_db())
|
loop.run_until_complete(mongo_service.init_db())
|
||||||
|
logger.info("✅ Database initialized successfully")
|
||||||
|
|
||||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
# ✅ Test MongoDB connection
|
||||||
|
from pymongo import MongoClient
|
||||||
|
client = MongoClient(app.config['MONGODB_URI'])
|
||||||
|
client.admin.command('ismaster')
|
||||||
|
logger.info("✅ MongoDB connection verified")
|
||||||
|
|
||||||
|
logger.info("✅ OpenLearnX backend starting...")
|
||||||
|
logger.info(f"✅ Admin panel available at: http://localhost:3000/admin/login")
|
||||||
|
logger.info(f"✅ API health check: http://127.0.0.1:5000")
|
||||||
|
logger.info(f"✅ Admin health check: http://127.0.0.1:5000/api/admin/health")
|
||||||
|
|
||||||
|
# ✅ Log admin token for debugging
|
||||||
|
admin_token = os.getenv('ADMIN_TOKEN', 'admin-secret-key')
|
||||||
|
logger.info(f"✅ Admin token configured: {admin_token[:8]}...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to initialize: {str(e)}")
|
||||||
|
logger.error("Make sure MongoDB is running and accessible")
|
||||||
|
|
||||||
|
# ✅ Enhanced Flask app configuration
|
||||||
|
app.run(
|
||||||
|
debug=True,
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=5000,
|
||||||
|
threaded=True # Better for handling multiple requests
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pymongo import MongoClient
|
||||||
|
import os
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
# MongoDB connection
|
||||||
|
mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
|
||||||
|
client = MongoClient(mongo_uri)
|
||||||
|
db = client.openlearnx
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
print(f"Admin auth check - Header: {auth_header}")
|
||||||
|
|
||||||
|
if not auth_header:
|
||||||
|
print("❌ No Authorization header")
|
||||||
|
return jsonify({"error": "No authorization header provided"}), 401
|
||||||
|
|
||||||
|
if not auth_header.startswith('Bearer '):
|
||||||
|
print("❌ Invalid authorization format")
|
||||||
|
return jsonify({"error": "Invalid authorization format"}), 401
|
||||||
|
|
||||||
|
token = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None
|
||||||
|
print(f"Extracted token: '{token}'")
|
||||||
|
|
||||||
|
# Check environment variable first, then fallback to default
|
||||||
|
expected_token = os.getenv('ADMIN_TOKEN')
|
||||||
|
if not expected_token:
|
||||||
|
expected_token = 'admin-secret-key'
|
||||||
|
|
||||||
|
print(f"Expected token: '{expected_token}'")
|
||||||
|
print(f"Environment ADMIN_TOKEN: '{os.getenv('ADMIN_TOKEN')}'")
|
||||||
|
|
||||||
|
# Strip any whitespace from both tokens
|
||||||
|
if token and expected_token:
|
||||||
|
if token.strip() == expected_token.strip():
|
||||||
|
print("✅ Admin authentication successful")
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
print("❌ Token mismatch")
|
||||||
|
return jsonify({"error": "Invalid admin token"}), 401
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Admin auth error: {str(e)}")
|
||||||
|
return jsonify({"error": "Authentication failed"}), 500
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def serialize_course(course):
|
||||||
|
"""Convert MongoDB document to JSON-serializable format"""
|
||||||
|
if course:
|
||||||
|
if '_id' in course:
|
||||||
|
del course['_id']
|
||||||
|
return course
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_to_embed_url(youtube_url):
|
||||||
|
"""Convert YouTube watch URL to embed URL - ENHANCED VERSION"""
|
||||||
|
if not youtube_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "youtu.be/" in youtube_url:
|
||||||
|
video_id = youtube_url.split("youtu.be/")[1].split("?")[0].split("&")[0]
|
||||||
|
elif "youtube.com/watch?v=" in youtube_url:
|
||||||
|
video_id = youtube_url.split("v=")[1].split("&")[0]
|
||||||
|
elif "youtube.com/embed/" in youtube_url:
|
||||||
|
return youtube_url
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
video_id = video_id.strip()
|
||||||
|
return f"https://www.youtube.com/embed/{video_id}?rel=0&modestbranding=1"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error converting YouTube URL: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@bp.route("/test", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def test_admin():
|
||||||
|
"""Test admin authentication"""
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": "Admin authentication working",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
@bp.route("/dashboard", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def admin_dashboard():
|
||||||
|
"""Get admin dashboard statistics"""
|
||||||
|
try:
|
||||||
|
total_courses = db.courses.count_documents({})
|
||||||
|
total_lessons = db.lessons.count_documents({})
|
||||||
|
active_students = db.users.count_documents({"status": "active"}) or 2341
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_courses": total_courses,
|
||||||
|
"total_lessons": total_lessons,
|
||||||
|
"active_students": active_students,
|
||||||
|
"completion_rate": 78
|
||||||
|
}
|
||||||
|
return jsonify(stats)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Dashboard error: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/courses", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def get_admin_courses():
|
||||||
|
"""Get all courses for admin management"""
|
||||||
|
try:
|
||||||
|
print("Fetching courses from database...")
|
||||||
|
courses = list(db.courses.find({}, {"_id": 0}))
|
||||||
|
print(f"Found {len(courses)} courses")
|
||||||
|
|
||||||
|
for course in courses:
|
||||||
|
course["students"] = course.get("students", 0)
|
||||||
|
course["status"] = "published"
|
||||||
|
|
||||||
|
return jsonify(courses)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching courses: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/courses", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_course():
|
||||||
|
"""Create new course"""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
print(f"Creating course with data: {data}") # Debug log
|
||||||
|
|
||||||
|
course_id = data.get('id') or f"{data.get('title', '').lower().replace(' ', '-').replace('&', 'and')}-course"
|
||||||
|
|
||||||
|
existing_course = db.courses.find_one({"id": course_id})
|
||||||
|
if existing_course:
|
||||||
|
return jsonify({"error": "Course with this ID already exists"}), 400
|
||||||
|
|
||||||
|
new_course = {
|
||||||
|
"id": course_id,
|
||||||
|
"title": data.get('title'),
|
||||||
|
"subject": data.get('subject'),
|
||||||
|
"description": data.get('description'),
|
||||||
|
"difficulty": data.get('difficulty'),
|
||||||
|
"mentor": data.get('mentor', '5t4l1n'),
|
||||||
|
"video_url": data.get('video_url'),
|
||||||
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"students": 0,
|
||||||
|
"progress": 0,
|
||||||
|
"modules": []
|
||||||
|
}
|
||||||
|
|
||||||
|
result = db.courses.insert_one(new_course)
|
||||||
|
print(f"Course created with ID: {result.inserted_id}")
|
||||||
|
|
||||||
|
# Remove _id field before returning
|
||||||
|
new_course_response = serialize_course(new_course)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "course": new_course_response}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating course: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/courses/<course_id>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_course(course_id):
|
||||||
|
"""Update existing course - FIXED VERSION"""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
print(f"Updating course {course_id} with data: {data}") # Debug log
|
||||||
|
|
||||||
|
update_data = {
|
||||||
|
"title": data.get('title'),
|
||||||
|
"subject": data.get('subject'),
|
||||||
|
"description": data.get('description'),
|
||||||
|
"difficulty": data.get('difficulty'),
|
||||||
|
"mentor": data.get('mentor'),
|
||||||
|
"video_url": data.get('video_url'),
|
||||||
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
||||||
|
"updated_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove None values
|
||||||
|
update_data = {k: v for k, v in update_data.items() if v is not None}
|
||||||
|
print(f"Filtered update data: {update_data}") # Debug log
|
||||||
|
|
||||||
|
result = db.courses.update_one(
|
||||||
|
{"id": course_id},
|
||||||
|
{"$set": update_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Update result: matched={result.matched_count}, modified={result.modified_count}") # Debug log
|
||||||
|
|
||||||
|
if result.matched_count == 0:
|
||||||
|
return jsonify({"error": "Course not found"}), 404
|
||||||
|
|
||||||
|
# Get updated course without _id field
|
||||||
|
updated_course = db.courses.find_one({"id": course_id}, {"_id": 0})
|
||||||
|
return jsonify({"success": True, "course": updated_course})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating course: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/courses/<course_id>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_course(course_id):
|
||||||
|
"""Delete course"""
|
||||||
|
try:
|
||||||
|
print(f"Deleting course: {course_id}") # Debug log
|
||||||
|
|
||||||
|
result = db.courses.delete_one({"id": course_id})
|
||||||
|
|
||||||
|
if result.deleted_count == 0:
|
||||||
|
return jsonify({"error": "Course not found"}), 404
|
||||||
|
|
||||||
|
# Also delete related lessons
|
||||||
|
lesson_result = db.lessons.delete_many({"course_id": course_id})
|
||||||
|
print(f"Deleted {lesson_result.deleted_count} related lessons") # Debug log
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "Course deleted successfully"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting course: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/courses/<course_id>/modules", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def add_module(course_id):
|
||||||
|
"""Add module to course"""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
module = {
|
||||||
|
"id": data.get('id') or str(uuid.uuid4()),
|
||||||
|
"title": data.get('title'),
|
||||||
|
"lessons": []
|
||||||
|
}
|
||||||
|
|
||||||
|
result = db.courses.update_one(
|
||||||
|
{"id": course_id},
|
||||||
|
{"$push": {"modules": module}}
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.matched_count == 0:
|
||||||
|
return jsonify({"error": "Course not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({"success": True, "module": module})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/courses/<course_id>/lessons", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def add_lesson(course_id):
|
||||||
|
"""Add lesson to course"""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
lesson = {
|
||||||
|
"id": data.get('id') or str(uuid.uuid4()),
|
||||||
|
"course_id": course_id,
|
||||||
|
"title": data.get('title'),
|
||||||
|
"type": data.get('type', 'video'),
|
||||||
|
"duration": data.get('duration'),
|
||||||
|
"description": data.get('description'),
|
||||||
|
"content": data.get('content'),
|
||||||
|
"video_url": data.get('video_url'),
|
||||||
|
"embed_url": convert_to_embed_url(data.get('video_url')) if data.get('video_url') else None,
|
||||||
|
"created_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Insert lesson
|
||||||
|
db.lessons.insert_one(lesson)
|
||||||
|
|
||||||
|
# Remove _id field before returning
|
||||||
|
lesson_response = serialize_course(lesson)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "lesson": lesson_response})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/initialize", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def initialize_default_courses():
|
||||||
|
"""Initialize database with default courses"""
|
||||||
|
try:
|
||||||
|
existing_count = db.courses.count_documents({})
|
||||||
|
if existing_count > 0:
|
||||||
|
return jsonify({"message": f"Courses already initialized ({existing_count} courses found)"}), 200
|
||||||
|
|
||||||
|
default_courses = [
|
||||||
|
{
|
||||||
|
"id": "python-course",
|
||||||
|
"title": "Python Programming Mastery",
|
||||||
|
"subject": "Programming",
|
||||||
|
"description": "Learn Python from basics to advanced concepts including turtle graphics",
|
||||||
|
"difficulty": "Beginner to Advanced",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp",
|
||||||
|
"embed_url": "https://www.youtube.com/embed/SsH8GJlqUIg?rel=0&modestbranding=1",
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"students": 1250,
|
||||||
|
"progress": 0,
|
||||||
|
"modules": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "java-course",
|
||||||
|
"title": "Java Development Bootcamp",
|
||||||
|
"subject": "Programming",
|
||||||
|
"description": "Master Java programming with object-oriented concepts",
|
||||||
|
"difficulty": "Intermediate",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp",
|
||||||
|
"embed_url": "https://www.youtube.com/embed/SsH8GJlqUIg?rel=0&modestbranding=1",
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"students": 890,
|
||||||
|
"progress": 0,
|
||||||
|
"modules": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ethical-hacking-course",
|
||||||
|
"title": "Ethical Hacking & Cybersecurity",
|
||||||
|
"subject": "Cybersecurity",
|
||||||
|
"description": "Learn ethical hacking techniques and penetration testing",
|
||||||
|
"difficulty": "Advanced",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/cDnX0vyNTaE?si=ZXNI4hv2HlWN7eCS",
|
||||||
|
"embed_url": "https://www.youtube.com/embed/cDnX0vyNTaE?rel=0&modestbranding=1",
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"students": 567,
|
||||||
|
"progress": 0,
|
||||||
|
"modules": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dark-web-hosting-course",
|
||||||
|
"title": "Learn Dark Web Hosting",
|
||||||
|
"subject": "Cybersecurity",
|
||||||
|
"description": "Understanding dark web infrastructure, Tor networks, and secure hosting practices for cybersecurity professionals",
|
||||||
|
"difficulty": "Expert",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/Z4_USAMVhYs?si=Y_ThVisph5ekM44U",
|
||||||
|
"embed_url": "https://www.youtube.com/embed/Z4_USAMVhYs?rel=0&modestbranding=1",
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"students": 234,
|
||||||
|
"progress": 0,
|
||||||
|
"modules": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = db.courses.insert_many(default_courses)
|
||||||
|
print(f"Initialized {len(result.inserted_ids)} default courses")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Default courses initialized successfully",
|
||||||
|
"courses_created": len(result.inserted_ids)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing courses: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/stats", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def get_admin_stats():
|
||||||
|
"""Get detailed admin statistics"""
|
||||||
|
try:
|
||||||
|
total_courses = db.courses.count_documents({})
|
||||||
|
total_lessons = db.lessons.count_documents({})
|
||||||
|
|
||||||
|
# Course statistics by subject
|
||||||
|
pipeline = [
|
||||||
|
{"$group": {"_id": "$subject", "count": {"$sum": 1}}}
|
||||||
|
]
|
||||||
|
subjects = list(db.courses.aggregate(pipeline))
|
||||||
|
|
||||||
|
# Course statistics by difficulty
|
||||||
|
pipeline = [
|
||||||
|
{"$group": {"_id": "$difficulty", "count": {"$sum": 1}}}
|
||||||
|
]
|
||||||
|
difficulties = list(db.courses.aggregate(pipeline))
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_courses": total_courses,
|
||||||
|
"total_lessons": total_lessons,
|
||||||
|
"subjects": subjects,
|
||||||
|
"difficulties": difficulties,
|
||||||
|
"last_updated": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(stats)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting stats: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@bp.route("/health", methods=["GET"])
|
||||||
|
def admin_health():
|
||||||
|
"""Admin health check endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
"status": "Admin API is healthy",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"database_connected": True,
|
||||||
|
"endpoints": [
|
||||||
|
"GET /api/admin/dashboard",
|
||||||
|
"GET /api/admin/courses",
|
||||||
|
"POST /api/admin/courses",
|
||||||
|
"PUT /api/admin/courses/<id>",
|
||||||
|
"DELETE /api/admin/courses/<id>",
|
||||||
|
"POST /api/admin/initialize",
|
||||||
|
"GET /api/admin/test",
|
||||||
|
"GET /api/admin/stats"
|
||||||
|
]
|
||||||
|
})
|
||||||
+82
-32
@@ -1,43 +1,93 @@
|
|||||||
from flask import Blueprint, jsonify, current_app
|
from flask import Blueprint, jsonify, current_app
|
||||||
import asyncio
|
from pymongo import MongoClient
|
||||||
from bson import ObjectId
|
import os
|
||||||
|
|
||||||
bp = Blueprint('courses', __name__)
|
bp = Blueprint('courses', __name__)
|
||||||
|
|
||||||
# Remove trailing slash from route definition
|
# MongoDB connection
|
||||||
|
mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/')
|
||||||
|
client = MongoClient(mongo_uri)
|
||||||
|
db = client.openlearnx
|
||||||
|
|
||||||
@bp.route("/", methods=["GET"])
|
@bp.route("/", methods=["GET"])
|
||||||
@bp.route("", methods=["GET"]) # Add this line to handle both cases
|
@bp.route("", methods=["GET"])
|
||||||
def list_courses():
|
def list_courses():
|
||||||
|
"""Get all courses - DYNAMIC from database"""
|
||||||
try:
|
try:
|
||||||
# Your existing course logic here
|
courses = list(db.courses.find({}, {"_id": 0}))
|
||||||
# Mock data for now since you're having DB async issues
|
|
||||||
courses = [
|
course_list = []
|
||||||
{
|
for course in courses:
|
||||||
"id": "python-course",
|
course_data = {
|
||||||
"title": "Python Programming Mastery",
|
"id": course.get("id"),
|
||||||
"subject": "Programming",
|
"title": course.get("title"),
|
||||||
"description": "Learn Python from basics to advanced concepts",
|
"subject": course.get("subject"),
|
||||||
"difficulty": "Beginner to Advanced",
|
"description": course.get("description"),
|
||||||
"progress": 0
|
"difficulty": course.get("difficulty"),
|
||||||
},
|
"mentor": course.get("mentor"),
|
||||||
{
|
"video_url": course.get("video_url"),
|
||||||
"id": "java-course",
|
"embed_url": course.get("embed_url"),
|
||||||
"title": "Java Development Bootcamp",
|
"progress": course.get("progress", 0)
|
||||||
"subject": "Programming",
|
|
||||||
"description": "Master Java programming with object-oriented concepts",
|
|
||||||
"difficulty": "Intermediate",
|
|
||||||
"progress": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ethical-hacking-course",
|
|
||||||
"title": "Ethical Hacking & Cybersecurity",
|
|
||||||
"subject": "Cybersecurity",
|
|
||||||
"description": "Learn ethical hacking techniques and penetration testing",
|
|
||||||
"difficulty": "Advanced",
|
|
||||||
"progress": 0
|
|
||||||
}
|
}
|
||||||
]
|
course_list.append(course_data)
|
||||||
return jsonify(courses)
|
|
||||||
|
return jsonify(course_list)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in list_courses: {e}")
|
print(f"Error in list_courses: {e}")
|
||||||
return jsonify({"error": "Failed to fetch courses"}), 500
|
return jsonify({"error": "Failed to fetch courses"}), 500
|
||||||
|
|
||||||
|
@bp.route("/<course_id>", methods=["GET"])
|
||||||
|
def get_course(course_id):
|
||||||
|
"""Get specific course details - DYNAMIC"""
|
||||||
|
try:
|
||||||
|
course = db.courses.find_one({"id": course_id}, {"_id": 0})
|
||||||
|
|
||||||
|
if not course:
|
||||||
|
return jsonify({"error": "Course not found"}), 404
|
||||||
|
|
||||||
|
return jsonify(course)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in get_course: {e}")
|
||||||
|
return jsonify({"error": "Failed to fetch course"}), 500
|
||||||
|
|
||||||
|
@bp.route("/<course_id>/lessons/<lesson_id>", methods=["GET"])
|
||||||
|
def get_lesson(course_id, lesson_id):
|
||||||
|
"""Get specific lesson content - DYNAMIC"""
|
||||||
|
try:
|
||||||
|
lesson = db.lessons.find_one({"id": lesson_id, "course_id": course_id}, {"_id": 0})
|
||||||
|
|
||||||
|
if not lesson:
|
||||||
|
return jsonify({"error": "Lesson not found"}), 404
|
||||||
|
|
||||||
|
return jsonify(lesson)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in get_lesson: {e}")
|
||||||
|
return jsonify({"error": "Failed to fetch lesson"}), 500
|
||||||
|
|
||||||
|
@bp.route("/<course_id>/lessons/<lesson_id>/complete", methods=["POST"])
|
||||||
|
def mark_lesson_complete(course_id, lesson_id):
|
||||||
|
"""Mark a lesson as completed for the user"""
|
||||||
|
try:
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Lesson {lesson_id} marked as complete",
|
||||||
|
"progress_updated": 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"""
|
||||||
|
try:
|
||||||
|
progress = {
|
||||||
|
"course_id": course_id,
|
||||||
|
"completion_percentage": 25,
|
||||||
|
"lessons_completed": [],
|
||||||
|
"total_lessons": 4,
|
||||||
|
"last_accessed": "2025-01-26T23:30:00Z",
|
||||||
|
"time_spent": "2 hours 15 minutes"
|
||||||
|
}
|
||||||
|
return jsonify(progress)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
+44
-5
@@ -15,13 +15,16 @@ async def seed_courses():
|
|||||||
"subject": "Programming",
|
"subject": "Programming",
|
||||||
"description": "Learn Python from basics to advanced concepts including web development, data science, and automation.",
|
"description": "Learn Python from basics to advanced concepts including web development, data science, and automation.",
|
||||||
"difficulty": "Beginner to Advanced",
|
"difficulty": "Beginner to Advanced",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"id": "python-basics",
|
"id": "python-basics",
|
||||||
"title": "Python Fundamentals",
|
"title": "Python Fundamentals",
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{"id": "variables", "title": "Variables and Data Types", "type": "text"},
|
{"id": "variables", "title": "Variables and Data Types", "type": "video", "video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp"},
|
||||||
{"id": "functions", "title": "Functions and Modules", "type": "code"}
|
{"id": "functions", "title": "Functions and Modules", "type": "code"},
|
||||||
|
{"id": "turtle-graphics", "title": "Python Turtle Graphics", "type": "video", "video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -32,6 +35,8 @@ async def seed_courses():
|
|||||||
"subject": "Programming",
|
"subject": "Programming",
|
||||||
"description": "Master Java programming with object-oriented concepts, Spring framework, and enterprise development.",
|
"description": "Master Java programming with object-oriented concepts, Spring framework, and enterprise development.",
|
||||||
"difficulty": "Intermediate",
|
"difficulty": "Intermediate",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/SsH8GJlqUIg?si=cK7KW_sM0uf95lEp",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"id": "java-oop",
|
"id": "java-oop",
|
||||||
@@ -49,13 +54,44 @@ async def seed_courses():
|
|||||||
"subject": "Cybersecurity",
|
"subject": "Cybersecurity",
|
||||||
"description": "Learn ethical hacking techniques, penetration testing, and cybersecurity fundamentals to protect systems.",
|
"description": "Learn ethical hacking techniques, penetration testing, and cybersecurity fundamentals to protect systems.",
|
||||||
"difficulty": "Advanced",
|
"difficulty": "Advanced",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/cDnX0vyNTaE?si=ZXNI4hv2HlWN7eCS",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"id": "recon",
|
"id": "recon",
|
||||||
"title": "Reconnaissance and Information Gathering",
|
"title": "Reconnaissance and Information Gathering",
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{"id": "footprinting", "title": "Footprinting Techniques", "type": "text"},
|
{"id": "footprinting", "title": "Footprinting Techniques", "type": "video", "video_url": "https://youtu.be/cDnX0vyNTaE?si=ZXNI4hv2HlWN7eCS"},
|
||||||
{"id": "scanning", "title": "Network Scanning", "type": "code"}
|
{"id": "scanning", "title": "Network Scanning", "type": "code"},
|
||||||
|
{"id": "enumeration", "title": "Service Enumeration", "type": "text"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "dark-web-hosting-course",
|
||||||
|
"title": "Learn Dark Web Hosting",
|
||||||
|
"subject": "Cybersecurity",
|
||||||
|
"description": "Understanding dark web infrastructure, Tor networks, and secure hosting practices for cybersecurity professionals.",
|
||||||
|
"difficulty": "Expert",
|
||||||
|
"mentor": "5t4l1n",
|
||||||
|
"video_url": "https://youtu.be/Z4_USAMVhYs?si=Y_ThVisph5ekM44U",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "tor-basics",
|
||||||
|
"title": "Tor Network Fundamentals",
|
||||||
|
"lessons": [
|
||||||
|
{"id": "tor-intro", "title": "Introduction to Tor Network", "type": "video", "video_url": "https://youtu.be/Z4_USAMVhYs?si=Y_ThVisph5ekM44U"},
|
||||||
|
{"id": "onion-services", "title": "Setting Up Onion Services", "type": "code"},
|
||||||
|
{"id": "security-practices", "title": "Security Best Practices", "type": "text"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hosting-setup",
|
||||||
|
"title": "Dark Web Hosting Setup",
|
||||||
|
"lessons": [
|
||||||
|
{"id": "server-config", "title": "Server Configuration", "type": "code"},
|
||||||
|
{"id": "anonymity", "title": "Maintaining Anonymity", "type": "text"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -63,8 +99,11 @@ async def seed_courses():
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Clear existing courses first
|
||||||
|
await mongo_service.db.courses.delete_many({})
|
||||||
|
# Insert updated courses
|
||||||
await mongo_service.db.courses.insert_many(courses)
|
await mongo_service.db.courses.insert_many(courses)
|
||||||
print("✅ Courses seeded successfully!")
|
print("✅ Courses with mentor and video links seeded successfully!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error seeding courses: {e}")
|
print(f"❌ Error seeding courses: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AdminLogin() {
|
||||||
|
const [adminToken, setAdminToken] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
|
||||||
|
// Check if already authenticated
|
||||||
|
const checkExistingAuth = async () => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token === 'admin-secret-key') {
|
||||||
|
try {
|
||||||
|
// Verify token with API
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/admin/courses', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Existing token valid, redirecting to dashboard')
|
||||||
|
window.location.href = '/admin'
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Token invalid, remove it
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token verification failed:', error)
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(checkExistingAuth, 200)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const handleLogin = async (e?: React.FormEvent) => {
|
||||||
|
if (e) e.preventDefault()
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!adminToken.trim()) {
|
||||||
|
setError('Please enter admin token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Attempting login with token:', adminToken)
|
||||||
|
|
||||||
|
// Test API connection first
|
||||||
|
const testResponse = await fetch('http://127.0.0.1:5000/api/admin/courses', {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (testResponse.ok) {
|
||||||
|
console.log('API accepts token, saving to localStorage')
|
||||||
|
|
||||||
|
// Clear any existing token first
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
|
||||||
|
// Save new token
|
||||||
|
localStorage.setItem('admin_token', adminToken)
|
||||||
|
|
||||||
|
// Verify it was saved
|
||||||
|
const savedToken = localStorage.getItem('admin_token')
|
||||||
|
console.log('Token saved verification:', savedToken)
|
||||||
|
|
||||||
|
if (savedToken === adminToken) {
|
||||||
|
console.log('✅ Token saved successfully, redirecting...')
|
||||||
|
|
||||||
|
// Use window.location for reliable redirect
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/admin'
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
setError('Failed to save authentication. Please try again.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('API rejected token')
|
||||||
|
setError('Invalid admin credentials. Please contact administrator.')
|
||||||
|
setAdminToken('')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err)
|
||||||
|
setError('Connection failed. Make sure backend is running.')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClient) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-white font-bold text-xl">OL</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
OpenLearnX Admin
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Enter your admin credentials to manage courses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Admin Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter admin token"
|
||||||
|
value={adminToken}
|
||||||
|
onChange={(e) => setAdminToken(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded text-sm">
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Login Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !adminToken.trim()}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium py-2 px-4 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
|
||||||
|
Authenticating...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'🔐 Login to Admin Panel'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Security Notice */}
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>! 👋
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Plus, Edit, Trash2, Eye, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Course {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subject: string
|
||||||
|
description: string
|
||||||
|
difficulty: string
|
||||||
|
mentor: string
|
||||||
|
video_url: string
|
||||||
|
students: number
|
||||||
|
status: 'published' | 'draft'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
const [courses, setCourses] = useState<Course[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null)
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
total_courses: 0,
|
||||||
|
total_lessons: 0,
|
||||||
|
active_students: 0,
|
||||||
|
completion_rate: 0
|
||||||
|
})
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Enhanced authentication with API verification to prevent redirect loops
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
// Add delay to prevent race conditions
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
console.log('Dashboard - checking token:', token)
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log('Dashboard - no token found')
|
||||||
|
router.push('/admin/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === 'admin-secret-key') {
|
||||||
|
console.log('Dashboard - token format valid, verifying with API...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/admin/courses', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('✅ Dashboard - API confirms token valid')
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
console.log('❌ Dashboard - API rejects token')
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
} catch (apiError) {
|
||||||
|
console.error('Dashboard - API check failed:', apiError)
|
||||||
|
// Don't redirect on API error, might be temporary network issue
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Dashboard - invalid token format')
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard - auth check error:', error)
|
||||||
|
router.push('/admin/login')
|
||||||
|
} finally {
|
||||||
|
setAuthChecked(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
await Promise.all([fetchCourses(), fetchStats()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching courses...')
|
||||||
|
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/admin/courses', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer admin-secret-key',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Response status:', response.status)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Received data:', data)
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setCourses(data)
|
||||||
|
console.log('✅ Courses set successfully:', data.length, 'courses')
|
||||||
|
} else {
|
||||||
|
console.error('❌ API returned non-array data:', data)
|
||||||
|
setCourses([])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching courses:', error)
|
||||||
|
setCourses([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/admin/dashboard', {
|
||||||
|
headers: { 'Authorization': 'Bearer admin-secret-key' }
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
setStats(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateCourse = async (formData: any) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/admin/courses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer admin-secret-key'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchData()
|
||||||
|
setShowAddForm(false)
|
||||||
|
alert('Course created successfully!')
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(`Error: ${error.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating course:', error)
|
||||||
|
alert('Failed to create course')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateCourse = async (courseId: string, formData: any) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:5000/api/admin/courses/${courseId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer admin-secret-key'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchData()
|
||||||
|
setEditingCourse(null)
|
||||||
|
alert('Course updated successfully!')
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(`Error: ${error.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating course:', error)
|
||||||
|
alert('Failed to update course')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteCourse = async (courseId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this course? This action cannot be undone.')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:5000/api/admin/courses/${courseId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer admin-secret-key' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchData()
|
||||||
|
alert('Course deleted successfully!')
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(`Error: ${error.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting course:', error)
|
||||||
|
alert('Failed to delete course')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeDefaultCourses = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/admin/initialize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer admin-secret-key' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchData()
|
||||||
|
alert('Default courses initialized!')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing courses:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading until auth is checked
|
||||||
|
if (!isClient || !authChecked) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen 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-600">Checking authentication...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show redirect message if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen 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-600">Redirecting to login...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">OL</span>
|
||||||
|
</div>
|
||||||
|
<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 rounded-full text-xs font-medium">
|
||||||
|
DYNAMIC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md"
|
||||||
|
title="Refresh Data"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-600">Welcome, 5t4l1n! 👋</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Course Management</h2>
|
||||||
|
<p className="text-gray-600">Add, edit, or remove courses dynamically</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={initializeDefaultCourses}
|
||||||
|
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<span>Initialize Default Courses</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Add New Course</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Total Courses</h3>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{stats.total_courses}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Active Students</h3>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{stats.active_students}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Total Lessons</h3>
|
||||||
|
<p className="text-2xl font-bold text-purple-600">{stats.total_lessons}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Completion Rate</h3>
|
||||||
|
<p className="text-2xl font-bold text-orange-600">{stats.completion_rate}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">All Courses</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 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-600">Loading courses...</p>
|
||||||
|
</div>
|
||||||
|
) : !Array.isArray(courses) || courses.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-gray-500 mb-4">No courses found. Initialize default courses or add a new one.</p>
|
||||||
|
<button
|
||||||
|
onClick={initializeDefaultCourses}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Initialize Default Courses
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Course
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Mentor
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Students
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<tr key={course.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{course.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{course.subject} • {course.difficulty}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{course.mentor}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{course.students?.toLocaleString() || 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/courses/${course.id}`, '_blank')}
|
||||||
|
className="text-blue-600 hover:text-blue-900"
|
||||||
|
title="View Course"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingCourse(course)}
|
||||||
|
className="text-green-600 hover:text-green-900"
|
||||||
|
title="Edit Course"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteCourse(course.id)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
title="Delete Course"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Course Modal */}
|
||||||
|
{showAddForm && (
|
||||||
|
<CourseFormModal
|
||||||
|
title="Add New Course"
|
||||||
|
onClose={() => setShowAddForm(false)}
|
||||||
|
onSubmit={handleCreateCourse}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Course Modal */}
|
||||||
|
{editingCourse && (
|
||||||
|
<CourseFormModal
|
||||||
|
title="Edit Course"
|
||||||
|
course={editingCourse}
|
||||||
|
onClose={() => setEditingCourse(null)}
|
||||||
|
onSubmit={(data) => handleUpdateCourse(editingCourse.id, data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Course Form Modal Component
|
||||||
|
function CourseFormModal({
|
||||||
|
title,
|
||||||
|
course,
|
||||||
|
onClose,
|
||||||
|
onSubmit
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
course?: Course
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: any) => void
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: course?.title || '',
|
||||||
|
subject: course?.subject || 'Programming',
|
||||||
|
description: course?.description || '',
|
||||||
|
difficulty: course?.difficulty || 'Beginner',
|
||||||
|
mentor: course?.mentor || '5t4l1n',
|
||||||
|
video_url: course?.video_url || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmbedUrl = (videoUrl: string) => {
|
||||||
|
if (!videoUrl) return null
|
||||||
|
|
||||||
|
let videoId = ''
|
||||||
|
if (videoUrl.includes('youtu.be/')) {
|
||||||
|
videoId = videoUrl.split('youtu.be/')[1]?.split('?')[0]
|
||||||
|
} else if (videoUrl.includes('youtube.com/watch?v=')) {
|
||||||
|
videoId = videoUrl.split('v=')[1]?.split('&')[0]
|
||||||
|
} else if (videoUrl.includes('youtube.com/embed/')) {
|
||||||
|
return videoUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoId ? `https://www.youtube.com/embed/${videoId}?rel=0&modestbranding=1` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl m-4 max-h-screen overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Course Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="e.g., Advanced React Development"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Subject *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="Programming">Programming</option>
|
||||||
|
<option value="Cybersecurity">Cybersecurity</option>
|
||||||
|
<option value="Web Development">Web Development</option>
|
||||||
|
<option value="Data Science">Data Science</option>
|
||||||
|
<option value="Mobile Development">Mobile Development</option>
|
||||||
|
<option value="DevOps">DevOps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Difficulty *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.difficulty}
|
||||||
|
onChange={(e) => setFormData({...formData, difficulty: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="Beginner">Beginner</option>
|
||||||
|
<option value="Intermediate">Intermediate</option>
|
||||||
|
<option value="Advanced">Advanced</option>
|
||||||
|
<option value="Expert">Expert</option>
|
||||||
|
<option value="Beginner to Advanced">Beginner to Advanced</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Mentor *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.mentor}
|
||||||
|
onChange={(e) => setFormData({...formData, mentor: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Instructor name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Description *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Course description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
YouTube Video URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.video_url}
|
||||||
|
onChange={(e) => setFormData({...formData, video_url: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://youtu.be/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Preview */}
|
||||||
|
{formData.video_url && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Video Preview
|
||||||
|
</label>
|
||||||
|
<div className="aspect-video rounded-lg overflow-hidden bg-gray-100">
|
||||||
|
<iframe
|
||||||
|
src={getEmbedUrl(formData.video_url) || ''}
|
||||||
|
title="Video Preview"
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{course ? 'Update Course' : 'Create Course'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,20 +6,20 @@ import { Loader2 } from "lucide-react"
|
|||||||
import { useAuth } from "@/context/auth-context"
|
import { useAuth } from "@/context/auth-context"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "react-hot-toast"
|
import { toast } from "react-hot-toast"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, use } from "react" // ✅ Added 'use' import
|
||||||
import type { Course } from "@/lib/types"
|
import type { Course } from "@/lib/types"
|
||||||
import api from "@/lib/api" // Corrected import: default import
|
import api from "@/lib/api"
|
||||||
|
|
||||||
interface CourseDetailPageProps {
|
interface CourseDetailPageProps {
|
||||||
params: {
|
params: Promise<{ // ✅ Changed to Promise
|
||||||
courseId: string
|
courseId: string
|
||||||
lessonId: string
|
lessonId: string
|
||||||
}
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseDetailPage({ params }: CourseDetailPageProps) {
|
export default function CourseDetailPage({ params }: CourseDetailPageProps) {
|
||||||
const { courseId, lessonId } = params
|
const { courseId, lessonId } = use(params) // ✅ Unwrap params using React.use()
|
||||||
const { user, firebaseUser, isLoadingAuth } = useAuth() // Allow firebaseUser
|
const { user, firebaseUser, isLoadingAuth } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [course, setCourse] = useState<Course | null>(null)
|
const [course, setCourse] = useState<Course | null>(null)
|
||||||
const [isLoadingCourse, setIsLoadingCourse] = useState(true)
|
const [isLoadingCourse, setIsLoadingCourse] = useState(true)
|
||||||
@@ -27,7 +27,6 @@ export default function CourseDetailPage({ params }: CourseDetailPageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoadingAuth && !user && !firebaseUser) {
|
if (!isLoadingAuth && !user && !firebaseUser) {
|
||||||
// Allow either MetaMask or Firebase user
|
|
||||||
toast.error("Please login to view courses.")
|
toast.error("Please login to view courses.")
|
||||||
router.push("/")
|
router.push("/")
|
||||||
return
|
return
|
||||||
@@ -37,7 +36,6 @@ export default function CourseDetailPage({ params }: CourseDetailPageProps) {
|
|||||||
setIsLoadingCourse(true)
|
setIsLoadingCourse(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
// --- ORIGINAL API CALL (UNCOMMENT WHEN BACKEND IS READY) ---
|
|
||||||
const response = await api.get<Course>(`/api/courses/${courseId}`)
|
const response = await api.get<Course>(`/api/courses/${courseId}`)
|
||||||
setCourse(response.data)
|
setCourse(response.data)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -50,7 +48,6 @@ export default function CourseDetailPage({ params }: CourseDetailPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user || firebaseUser) {
|
if (user || firebaseUser) {
|
||||||
// Only fetch if either user type is logged in
|
|
||||||
fetchCourse()
|
fetchCourse()
|
||||||
}
|
}
|
||||||
}, [user, firebaseUser, isLoadingAuth, router, courseId])
|
}, [user, firebaseUser, isLoadingAuth, router, courseId])
|
||||||
@@ -82,7 +79,11 @@ export default function CourseDetailPage({ params }: CourseDetailPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row min-h-[calc(100vh-64px)]">
|
<div className="flex flex-col md:flex-row min-h-[calc(100vh-64px)]">
|
||||||
<CourseSidebar courseId={course.id} modules={course.modules} activeLessonId={lessonId} />
|
<CourseSidebar
|
||||||
|
courseId={course.id}
|
||||||
|
modules={course.modules}
|
||||||
|
activeLessonId={lessonId}
|
||||||
|
/>
|
||||||
<div className="flex-1 p-4 md:p-8 overflow-y-auto">
|
<div className="flex-1 p-4 md:p-8 overflow-y-auto">
|
||||||
<LessonViewer courseId={course.id} lessonId={lessonId} />
|
<LessonViewer courseId={course.id} lessonId={lessonId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import { Loader2 } from "lucide-react"
|
|||||||
import { useAuth } from "@/context/auth-context"
|
import { useAuth } from "@/context/auth-context"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "react-hot-toast"
|
import { toast } from "react-hot-toast"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, use } from "react" // ✅ Added 'use' import
|
||||||
import type { Course } from "@/lib/types"
|
import type { Course } from "@/lib/types"
|
||||||
import api from "@/lib/api" // Corrected import: default import
|
import api from "@/lib/api"
|
||||||
|
|
||||||
interface CourseOverviewPageProps {
|
interface CourseOverviewPageProps {
|
||||||
params: {
|
params: Promise<{ // ✅ Changed to Promise
|
||||||
courseId: string
|
courseId: string
|
||||||
}
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseOverviewPage({ params }: CourseOverviewPageProps) {
|
export default function CourseOverviewPage({ params }: CourseOverviewPageProps) {
|
||||||
const { courseId } = params
|
const { courseId } = use(params) // ✅ Unwrap params using React.use()
|
||||||
const { user, firebaseUser, isLoadingAuth } = useAuth() // Allow firebaseUser
|
const { user, firebaseUser, isLoadingAuth } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [course, setCourse] = useState<Course | null>(null)
|
const [course, setCourse] = useState<Course | null>(null)
|
||||||
const [isLoadingCourse, setIsLoadingCourse] = useState(true)
|
const [isLoadingCourse, setIsLoadingCourse] = useState(true)
|
||||||
@@ -26,7 +26,6 @@ export default function CourseOverviewPage({ params }: CourseOverviewPageProps)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoadingAuth && !user && !firebaseUser) {
|
if (!isLoadingAuth && !user && !firebaseUser) {
|
||||||
// Allow either MetaMask or Firebase user
|
|
||||||
toast.error("Please login to view courses.")
|
toast.error("Please login to view courses.")
|
||||||
router.push("/")
|
router.push("/")
|
||||||
return
|
return
|
||||||
@@ -36,7 +35,6 @@ export default function CourseOverviewPage({ params }: CourseOverviewPageProps)
|
|||||||
setIsLoadingCourse(true)
|
setIsLoadingCourse(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
// --- ORIGINAL API CALL (UNCOMMENT WHEN BACKEND IS READY) ---
|
|
||||||
const response = await api.get<Course>(`/api/courses/${courseId}`)
|
const response = await api.get<Course>(`/api/courses/${courseId}`)
|
||||||
setCourse(response.data)
|
setCourse(response.data)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -49,7 +47,6 @@ export default function CourseOverviewPage({ params }: CourseOverviewPageProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user || firebaseUser) {
|
if (user || firebaseUser) {
|
||||||
// Only fetch if either user type is logged in
|
|
||||||
fetchCourse()
|
fetchCourse()
|
||||||
}
|
}
|
||||||
}, [user, firebaseUser, isLoadingAuth, router, courseId])
|
}, [user, firebaseUser, isLoadingAuth, router, courseId])
|
||||||
@@ -88,15 +85,25 @@ export default function CourseOverviewPage({ params }: CourseOverviewPageProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row min-h-[calc(100vh-64px)]">
|
<div className="flex flex-col md:flex-row min-h-[calc(100vh-64px)]">
|
||||||
<CourseSidebar courseId={course.id} modules={course.modules} activeLessonId="" />
|
<CourseSidebar
|
||||||
|
courseId={course.id}
|
||||||
|
modules={course.modules}
|
||||||
|
activeLessonId=""
|
||||||
|
/>
|
||||||
<div className="flex-1 p-4 md:p-8 overflow-y-auto">
|
<div className="flex-1 p-4 md:p-8 overflow-y-auto">
|
||||||
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
<Card className="bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold text-primary-purple">{course.title} Overview</CardTitle>
|
<CardTitle className="text-2xl font-bold text-primary-purple">
|
||||||
|
{course.title} Overview
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-lg text-gray-700 dark:text-gray-200">{course.description}</p>
|
<p className="text-lg text-gray-700 dark:text-gray-200">
|
||||||
<p className="text-gray-600 dark:text-gray-300">Select a lesson from the sidebar to begin.</p>
|
{course.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
Select a lesson from the sidebar to begin.
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useAuth } from "@/context/auth-context"
|
import { useAuth } from "@/context/auth-context"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
@@ -9,26 +8,37 @@ import { toast } from "react-hot-toast"
|
|||||||
import type { Lesson } from "@/lib/types"
|
import type { Lesson } from "@/lib/types"
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2, Play, BookOpen, Clock } from "lucide-react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import { api } from "@/lib/api"
|
|
||||||
|
|
||||||
interface LessonViewerProps {
|
interface LessonViewerProps {
|
||||||
courseId: string
|
courseId: string
|
||||||
lessonId: string
|
lessonId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LessonData {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
video_url: string
|
||||||
|
embed_url: string
|
||||||
|
duration: string
|
||||||
|
description: string
|
||||||
|
content: string
|
||||||
|
course_id: string
|
||||||
|
completed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
||||||
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth() // Check token for access
|
const { user, firebaseUser, isLoadingAuth, authMethod, token } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null)
|
const [lesson, setLesson] = useState<LessonData | null>(null)
|
||||||
const [isLoadingLesson, setIsLoadingLesson] = useState(true)
|
const [isLoadingLesson, setIsLoadingLesson] = useState(true)
|
||||||
const [isMarkingCompleted, setIsMarkingCompleted] = useState(false)
|
const [isMarkingCompleted, setIsMarkingCompleted] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoadingAuth && !user && !firebaseUser) {
|
if (!isLoadingAuth && !user && !firebaseUser) {
|
||||||
// Allow either MetaMask or Firebase user
|
|
||||||
toast.error("Please login to view lessons.")
|
toast.error("Please login to view lessons.")
|
||||||
router.push("/")
|
router.push("/")
|
||||||
return
|
return
|
||||||
@@ -38,19 +48,35 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
setIsLoadingLesson(true)
|
setIsLoadingLesson(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const response = await api.get<Lesson>(`/api/courses/${courseId}/lessons/${lessonId}`)
|
console.log(`Fetching lesson: ${courseId}/${lessonId}`) // Debug log
|
||||||
setLesson(response.data)
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/lessons/${lessonId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// Add auth header if token exists
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch lesson: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonData = await response.json()
|
||||||
|
console.log('Lesson data received:', lessonData) // Debug log
|
||||||
|
setLesson(lessonData)
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to fetch lesson:", err)
|
console.error("Failed to fetch lesson:", err)
|
||||||
setError(err.response?.data?.message || "Failed to load lesson.")
|
setError(err.message || "Failed to load lesson.")
|
||||||
toast.error(err.response?.data?.message || "Failed to load lesson.")
|
toast.error(err.message || "Failed to load lesson.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingLesson(false)
|
setIsLoadingLesson(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user || firebaseUser) {
|
if (user || firebaseUser) {
|
||||||
// Only fetch if either user type is logged in
|
|
||||||
fetchLesson()
|
fetchLesson()
|
||||||
}
|
}
|
||||||
}, [user, firebaseUser, isLoadingAuth, router, courseId, lessonId, token])
|
}, [user, firebaseUser, isLoadingAuth, router, courseId, lessonId, token])
|
||||||
@@ -58,24 +84,108 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
const markLessonCompleted = async () => {
|
const markLessonCompleted = async () => {
|
||||||
if (!lesson || lesson.completed || !token) {
|
if (!lesson || lesson.completed || !token) {
|
||||||
toast.error("Please connect your MetaMask wallet to mark lessons as completed.")
|
toast.error("Please connect your MetaMask wallet to mark lessons as completed.")
|
||||||
return // Ensure token exists for this action
|
return
|
||||||
}
|
}
|
||||||
setIsMarkingCompleted(true)
|
setIsMarkingCompleted(true)
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/courses/${courseId}/lessons/${lessonId}/complete`)
|
const response = await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/lessons/${lessonId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
setLesson((prev) => (prev ? { ...prev, completed: true } : null))
|
setLesson((prev) => (prev ? { ...prev, completed: true } : null))
|
||||||
toast.success("Lesson marked as completed!")
|
toast.success("Lesson marked as completed!")
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to mark lesson as completed')
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to mark lesson completed:", err)
|
console.error("Failed to mark lesson completed:", err)
|
||||||
toast.error(err.response?.data?.message || "Failed to mark lesson completed.")
|
toast.error("Failed to mark lesson completed.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsMarkingCompleted(false)
|
setIsMarkingCompleted(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed: Function to render markdown-like content with proper string handling
|
||||||
|
const renderContent = (content: string) => {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const result = []
|
||||||
|
let inCodeBlock = false
|
||||||
|
let codeBlockContent: string[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index++) {
|
||||||
|
const line = lines[index]
|
||||||
|
|
||||||
|
// Handle code blocks properly
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
if (!inCodeBlock) {
|
||||||
|
// Opening code block
|
||||||
|
inCodeBlock = true
|
||||||
|
codeBlockContent = []
|
||||||
|
} else {
|
||||||
|
// Closing code block - render accumulated content
|
||||||
|
inCodeBlock = false
|
||||||
|
result.push(
|
||||||
|
<div key={`code-${index}`} className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mt-4 mb-4 overflow-x-auto">
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<span className="text-white font-semibold">💻 Code Example:</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-sm">
|
||||||
|
<code>{codeBlockContent.join('\n')}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
codeBlockContent = []
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're inside a code block, accumulate content
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeBlockContent.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other markdown elements
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
result.push(
|
||||||
|
<h1 key={index} className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||||
|
{line.substring(2)}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
result.push(
|
||||||
|
<h2 key={index} className="text-xl font-semibold mb-3 text-gray-800 dark:text-gray-200 mt-6">
|
||||||
|
{line.substring(3)}
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
} else if (line.startsWith('- ')) {
|
||||||
|
result.push(
|
||||||
|
<li key={index} className="ml-4 text-gray-700 dark:text-gray-300 list-disc">
|
||||||
|
{line.substring(2)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else if (line.trim() === '') {
|
||||||
|
result.push(<br key={index} />)
|
||||||
|
} else if (line.trim() !== '') {
|
||||||
|
result.push(
|
||||||
|
<p key={index} className="text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoadingAuth || isLoadingLesson) {
|
if (isLoadingAuth || isLoadingLesson) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center flex-1">
|
<div className="flex justify-center items-center flex-1 min-h-[400px]">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary-purple" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary-purple" />
|
||||||
<span className="ml-2 text-lg">Loading lesson...</span>
|
<span className="ml-2 text-lg">Loading lesson...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +195,12 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center flex-1 text-red-500">
|
<div className="flex justify-center items-center flex-1 text-red-500">
|
||||||
<p>{error}</p>
|
<div className="text-center">
|
||||||
|
<p className="text-xl mb-4">{error}</p>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outline">
|
||||||
|
Retry Loading
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,31 +214,92 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex-1 bg-white shadow-md rounded-lg p-6 dark:bg-gray-800 dark:text-gray-100">
|
<div className="flex-1 space-y-6">
|
||||||
|
{/* Lesson Header Card */}
|
||||||
|
<Card className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold text-primary-purple">{lesson.title}</CardTitle>
|
<CardTitle className="text-2xl font-bold mb-2">{lesson.title}</CardTitle>
|
||||||
|
{lesson.description && (
|
||||||
|
<p className="text-blue-100 mb-4">{lesson.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center space-x-4 flex-wrap">
|
||||||
|
<span className="bg-white/20 px-3 py-1 rounded-full text-sm flex items-center">
|
||||||
|
{lesson.type === 'video' ? <Play className="w-4 h-4 mr-1" /> : <BookOpen className="w-4 h-4 mr-1" />}
|
||||||
|
{lesson.type === 'video' ? 'Video Lesson' : lesson.type === 'code' ? 'Coding Exercise' : 'Reading'}
|
||||||
|
</span>
|
||||||
|
{lesson.duration && (
|
||||||
|
<span className="bg-white/20 px-3 py-1 rounded-full text-sm flex items-center">
|
||||||
|
<Clock className="w-4 h-4 mr-1" />
|
||||||
|
{lesson.duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lesson.completed && (
|
||||||
|
<span className="bg-green-500 px-3 py-1 rounded-full text-sm">
|
||||||
|
✅ Completed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
</Card>
|
||||||
|
|
||||||
|
{/* Authentication Warning */}
|
||||||
{authMethod === "firebase" && !token && (
|
{authMethod === "firebase" && !token && (
|
||||||
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 rounded-md dark:bg-yellow-900 dark:border-yellow-600 dark:text-yellow-200">
|
<Card className="bg-yellow-50 border-l-4 border-yellow-500 dark:bg-yellow-900">
|
||||||
<p className="font-bold">Limited Access</p>
|
<CardContent className="pt-6">
|
||||||
<p>
|
<p className="font-bold text-yellow-700 dark:text-yellow-200">Limited Access</p>
|
||||||
|
<p className="text-yellow-700 dark:text-yellow-200">
|
||||||
You are logged in with email. Full functionality, including progress tracking and data persistence,
|
You are logged in with email. Full functionality, including progress tracking and data persistence,
|
||||||
requires connecting your MetaMask wallet.
|
requires connecting your MetaMask wallet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
{lesson.type === "video" && (
|
|
||||||
<div className="aspect-video w-full bg-gray-200 dark:bg-gray-700 rounded-md flex items-center justify-center text-gray-500">
|
{/* Video Player */}
|
||||||
<p>Video Player Placeholder: {lesson.content}</p>
|
{lesson.type === "video" && lesson.embed_url && (
|
||||||
{/* In a real app, you'd embed a video player here */}
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4 flex items-center">
|
||||||
|
<Play className="w-5 h-5 mr-2" />
|
||||||
|
Video Tutorial
|
||||||
|
</h3>
|
||||||
|
<div className="aspect-video rounded-lg overflow-hidden shadow-lg bg-gray-100">
|
||||||
|
<iframe
|
||||||
|
src={lesson.embed_url}
|
||||||
|
title={lesson.title}
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Lesson Content */}
|
||||||
|
<Card className="bg-white shadow-md rounded-lg dark:bg-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-primary-purple flex items-center">
|
||||||
|
<BookOpen className="w-5 h-5 mr-2" />
|
||||||
|
Lesson Content
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
{lesson.type === "text" && (
|
{lesson.type === "text" && (
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<ReactMarkdown>{lesson.content}</ReactMarkdown>
|
<ReactMarkdown>{lesson.content}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lesson.type === "video" && lesson.content && (
|
||||||
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderContent(lesson.content)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lesson.type === "code" && (
|
{lesson.type === "code" && (
|
||||||
<div className="bg-gray-900 text-white p-4 rounded-md font-mono text-sm overflow-x-auto">
|
<div className="bg-gray-900 text-white p-4 rounded-md font-mono text-sm overflow-x-auto">
|
||||||
<pre>
|
<pre>
|
||||||
@@ -131,6 +307,7 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lesson.type === "quiz" && (
|
{lesson.type === "quiz" && (
|
||||||
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-md text-primary-blue dark:text-blue-200">
|
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-md text-primary-blue dark:text-blue-200">
|
||||||
<p className="font-semibold">Quiz Link:</p>
|
<p className="font-semibold">Quiz Link:</p>
|
||||||
@@ -140,11 +317,16 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-end">
|
|
||||||
|
<CardFooter className="flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Course: {lesson.course_id} | Lesson: {lesson.id}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
{!lesson.completed && (
|
{!lesson.completed && (
|
||||||
<Button
|
<Button
|
||||||
onClick={markLessonCompleted}
|
onClick={markLessonCompleted}
|
||||||
disabled={isMarkingCompleted || !token} // Disable if no token
|
disabled={isMarkingCompleted || !token}
|
||||||
className="bg-primary-purple hover:bg-primary-purple/90 text-white"
|
className="bg-primary-purple hover:bg-primary-purple/90 text-white"
|
||||||
>
|
>
|
||||||
{isMarkingCompleted && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isMarkingCompleted && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
@@ -152,11 +334,29 @@ export function LessonViewer({ courseId, lessonId }: LessonViewerProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{lesson.completed && (
|
{lesson.completed && (
|
||||||
<Button disabled variant="outline" className="text-success border-success bg-transparent">
|
<Button disabled variant="outline" className="text-green-600 border-green-600 bg-transparent">
|
||||||
Completed
|
✅ Completed
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Practice Section for Learning */}
|
||||||
|
<Card className="border-l-4 border-l-green-500">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3 text-green-700">🚀 Practice Exercise</h3>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
Try modifying the code examples! Experiment with:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside mt-2 text-gray-700 dark:text-gray-300 space-y-1">
|
||||||
|
<li>Changing colors and shapes</li>
|
||||||
|
<li>Adding your own creative elements</li>
|
||||||
|
<li>Exploring different programming concepts</li>
|
||||||
|
<li>Building upon what you've learned</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -192,3 +192,18 @@ pnpm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## run mangodb in local for running
|
||||||
|
```
|
||||||
|
# Install MongoDB on Arch Linux
|
||||||
|
yay -S mongodb-bin
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
sudo systemctl start mongodb
|
||||||
|
sudo systemctl enable mongodb
|
||||||
|
|
||||||
|
# Verify it's running
|
||||||
|
sudo systemctl status mongodb
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
mongosh
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user