feat: enhance install script with hardware detection, refactor studio UI, and enable GPU support for whisper service

This commit is contained in:
2025-12-29 23:49:21 -03:00
parent ad56d8a81c
commit 6326cad39d
12 changed files with 678 additions and 856 deletions
-68
View File
@@ -1,68 +0,0 @@
#!/bin/bash
# OpenCCB Database Management Script
# This script handles creation, migrations and sqlx preparation for both microservices.
set -e
# Load environment variables if .env exists
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
# Fallback to DATABASE_URL if specific ones aren't set
# Note: For running locally against Docker Postgres, use localhost instead of db
CMS_URL=${CMS_DATABASE_URL:-$(echo $DATABASE_URL | sed 's/@db:/@localhost:/')}
LMS_URL=${LMS_DATABASE_URL:-$(echo $DATABASE_URL | sed 's/@db:/@localhost:/')}
if [ -z "$CMS_URL" ] || [ -z "$LMS_URL" ]; then
echo "Error: CMS_DATABASE_URL or LMS_DATABASE_URL is not set."
echo "Please check your .env file."
exit 1
fi
ACTION=$1
case $ACTION in
"setup")
echo "--- Creating Databases ---"
DATABASE_URL=$CMS_URL sqlx database create
DATABASE_URL=$LMS_URL sqlx database create
echo "Databases created (if they didn't exist)."
$0 migrate
;;
"migrate")
echo "--- Running CMS Migrations ---"
DATABASE_URL=$CMS_URL sqlx migrate run --source services/cms-service/migrations
echo "--- Running LMS Migrations ---"
DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations
echo "All migrations completed successfully."
;;
"prepare")
echo "--- Preparing SQLx queries for CMS ---"
cd services/cms-service && DATABASE_URL=$CMS_URL cargo sqlx prepare -- --all-targets --all-features && cd ../..
echo "--- Preparing SQLx queries for LMS ---"
cd services/lms-service && DATABASE_URL=$LMS_URL cargo sqlx prepare -- --all-targets --all-features && cd ../..
echo "SQLx preparation completed."
;;
"all")
$0 setup
$0 prepare
;;
*)
echo "Usage: $0 {setup|migrate|prepare|all}"
echo " setup: Creates databases and runs migrations"
echo " migrate: Runs database migrations for all services"
echo " prepare: Runs cargo sqlx prepare for offline compilation"
echo " all: Runs setup and prepare"
exit 1
;;
esac
+9 -12
View File
@@ -57,24 +57,21 @@ services:
NEXT_PUBLIC_LMS_API_URL: http://localhost:3002 NEXT_PUBLIC_LMS_API_URL: http://localhost:3002
whisper: whisper:
image: fedirz/faster-whisper-server:latest-cpu image: ${WHISPER_IMAGE:-fedirz/faster-whisper-server:latest-cpu}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- whisper_cache:/root/.cache/huggingface - whisper_cache:/root/.cache/huggingface
environment: environment:
# - WHISPER_MODEL=medium - DEVICE=${WHISPER_DEVICE:-cpu}
# - DEVICE=cpu
# GPU support commented out for stability if drivers missing
- DEVICE=cpu
# GPU support for RTX 2070 Super # GPU support for RTX 2070 Super
# deploy: deploy:
# resources: resources:
# reservations: reservations:
# devices: devices:
# - driver: nvidia - driver: nvidia
# count: 1 count: 1
# capabilities: [ gpu ] capabilities: [ gpu ]
e2e: e2e:
build: build:
-78
View File
@@ -1,78 +0,0 @@
#!/bin/bash
set -e
# Default values
API_URL="http://localhost:3001"
DEFAULT_EMAIL="admin@example.com"
DEFAULT_PASSWORD="password123"
DEFAULT_ORG="Default Organization"
DEFAULT_NAME="System Admin"
echo "==============================================="
echo " OpenCCB System Initialization Script"
echo "==============================================="
echo ""
# Check for curl and jq
if ! command -v curl &> /dev/null || ! command -v jq &> /dev/null; then
echo "Error: 'curl' and 'jq' are required but not installed."
echo "Please install them (e.g., sudo apt install curl jq) and try again."
exit 1
fi
echo "This script will create the initial Administrator account and Organization."
echo "Press Enter to use the default value."
echo ""
read -p "Enter Organization Name [$DEFAULT_ORG]: " ORG_NAME
ORG_NAME=${ORG_NAME:-$DEFAULT_ORG}
read -p "Enter Admin Full Name [$DEFAULT_NAME]: " FULL_NAME
FULL_NAME=${FULL_NAME:-$DEFAULT_NAME}
read -p "Enter Admin Email [$DEFAULT_EMAIL]: " EMAIL
EMAIL=${EMAIL:-$DEFAULT_EMAIL}
read -s -p "Enter Admin Password [$DEFAULT_PASSWORD]: " PASSWORD
echo ""
PASSWORD=${PASSWORD:-$DEFAULT_PASSWORD}
echo ""
echo "Creating Administrator..."
echo " Organization: $ORG_NAME"
echo " User: $FULL_NAME <$EMAIL>"
echo " Target API: $API_URL"
echo ""
# Prepare JSON payload
PAYLOAD=$(jq -n \
--arg email "$EMAIL" \
--arg password "$PASSWORD" \
--arg full_name "$FULL_NAME" \
--arg org_name "$ORG_NAME" \
--arg role "admin" \
'{email: $email, password: $password, full_name: $full_name, organization_name: $org_name, role: $role}')
# Execute Request
RESPONSE=$(curl -s -X POST "$API_URL/auth/register" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
# Check status based on JSON response structure (assuming successful response has a "token")
# We use grep here as a simple check, but could parse with jq for more robustness
if echo "$RESPONSE" | grep -q "token"; then
echo "✅ Success! Administrator created."
echo ""
echo "Login Credentials:"
echo "------------------"
echo "Email: $EMAIL"
echo "Password: (hidden)"
echo "Role: admin"
echo ""
echo "You can now log in at: http://localhost:3000/auth/login"
else
echo "❌ Failed to create administrator."
echo "Server Response:"
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
exit 1
fi
+111 -141
View File
@@ -1,14 +1,15 @@
#!/bin/bash #!/bin/bash
# OpenCCB Unified Installation Script # OpenCCB Unified Installation Script
# This script automates the setup of OpenCCB, including prerequisites, # This script automates the setup of OpenCCB:
# repository cloning, dependencies, and initial configuration. # 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
# 2. Hardware detection (NVIDIA GPU vs CPU)
# 3. Environment configuration (.env)
# 4. Database creation and migrations
# 5. System initialization (Admin account)
set -e set -e
REPO_URL="https://github.com/Nurfog/openccb.git" # Example URL, should be updated if needed
PROJECT_DIR="openccb"
echo "====================================================" echo "===================================================="
echo " 🚀 Welcome to the OpenCCB Installer" echo " 🚀 Welcome to the OpenCCB Installer"
echo "====================================================" echo "===================================================="
@@ -19,24 +20,18 @@ if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then
echo "✅ Project detected in current directory." echo "✅ Project detected in current directory."
PROJECT_ROOT=$(pwd) PROJECT_ROOT=$(pwd)
else else
echo "📂 Project not detected in current directory." # Simplification: assume we are in the project root if the script is running
if [ -d "$PROJECT_DIR" ]; then # but let's keep a basic check
echo "✅ Detected project folder '$PROJECT_DIR'." if [ -d "openccb" ]; then
cd "$PROJECT_DIR" cd openccb
PROJECT_ROOT=$(pwd) PROJECT_ROOT=$(pwd)
else else
echo "📥 Project folder not found. Cloning from $REPO_URL..." echo "⚠️ Please run this script from the root of the OpenCCB repository."
git clone "$REPO_URL" "$PROJECT_DIR" exit 1
cd "$PROJECT_DIR"
PROJECT_ROOT=$(pwd)
fi fi
fi fi
# 2. Prerequisite Installation # 2. Prerequisite Installation
echo ""
echo "🔍 Checking for prerequisites..."
# Function to check and install system packages (Ubuntu/Debian)
install_pkg() { install_pkg() {
if ! command -v "$1" &> /dev/null; then if ! command -v "$1" &> /dev/null; then
echo "🔧 Installing $1..." echo "🔧 Installing $1..."
@@ -46,7 +41,6 @@ install_pkg() {
fi fi
} }
# Check for essential tools
if [[ "$OSTYPE" == "linux-gnu"* ]]; then if [[ "$OSTYPE" == "linux-gnu"* ]]; then
if [ -f /etc/debian_version ]; then if [ -f /etc/debian_version ]; then
install_pkg "curl" install_pkg "curl"
@@ -54,191 +48,167 @@ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
install_pkg "jq" install_pkg "jq"
install_pkg "build-essential" install_pkg "build-essential"
install_pkg "docker.io" install_pkg "docker.io"
# On modern Ubuntu, docker compose is a plugin included with docker.io or available as docker-compose-v2
if ! docker compose version &> /dev/null; then if ! docker compose version &> /dev/null; then
install_pkg "docker-compose-v2" install_pkg "docker-compose-v2"
fi fi
else
echo "⚠️ Unsupported Linux distribution. Please ensure curl, git, jq, docker, and docker-compose are installed."
fi fi
fi fi
# Check for Rust
if ! command -v cargo &> /dev/null; then if ! command -v cargo &> /dev/null; then
echo "🔧 Installing Rust..." echo "🔧 Installing Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source $HOME/.cargo/env source $HOME/.cargo/env
else
echo "✅ Rust (Cargo) is already installed."
fi fi
# Check for Node.js
if ! command -v node &> /dev/null; then if ! command -v node &> /dev/null; then
echo "🔧 Node.js not found. Installing via NVM..." echo "🔧 Installing Node.js via NVM..."
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm install --lts nvm install --lts
else
echo "✅ Node.js $(node -v) is already installed."
fi fi
# Check for sqlx-cli
if ! command -v sqlx &> /dev/null; then if ! command -v sqlx &> /dev/null; then
echo "🔧 Installing sqlx-cli..." echo "🔧 Installing sqlx-cli..."
cargo install sqlx-cli --no-default-features --features postgres cargo install sqlx-cli --no-default-features --features postgres
else
echo "✅ sqlx-cli is already installed."
fi fi
# AI Stack Detection & Installation # 3. Hardware Detection
echo "" echo ""
echo "🤖 Setting up Local AI Stack..." echo "🔍 Detecting hardware..."
HAS_NVIDIA=false HAS_NVIDIA=false
if command -v nvidia-smi &> /dev/null; then if command -v nvidia-smi &> /dev/null && nvidia-smi -L &> /dev/null; then
if nvidia-smi -L &> /dev/null; then echo "🚀 NVIDIA GPU Detected!"
echo "🚀 NVIDIA GPU Detected!" HAS_NVIDIA=true
HAS_NVIDIA=true elif command -v lspci &> /dev/null && lspci | grep -i nvidia &> /dev/null; then
fi echo "🚀 NVIDIA GPU Detected (lspci)!"
fi HAS_NVIDIA=true
if [ "$HAS_NVIDIA" = false ] && command -v lspci &> /dev/null; then
if lspci | grep -i nvidia &> /dev/null; then
echo "🚀 NVIDIA GPU Detected (lspci)!"
HAS_NVIDIA=true
fi
fi
# Ollama Installation
if ! command -v ollama &> /dev/null; then
echo "🔧 Installing Ollama..."
curl -fsSL https://ollama.com/install.sh | sh
else else
echo "✅ Ollama is already installed." echo "💻 No NVIDIA GPU found. Using CPU mode."
fi fi
# Wait for Ollama to be ready
echo "⏳ Waiting for Ollama server to be ready..."
until curl -s http://localhost:11434/api/tags &> /dev/null; do
sleep 2
done
# Pre-download models based on hardware
if [ "$HAS_NVIDIA" = true ]; then
echo "📥 Downloading Llama 3 (optimized for GPU)..."
ollama pull llama3:8b
else
echo "📥 Downloading Phi-3 (lighter for CPU)..."
ollama pull phi3:mini
fi
# 3. Frontend Dependency Installation
echo ""
echo "📦 Installing frontend dependencies..."
for dir in "web/studio" "web/experience"; do
if [ -d "$dir" ]; then
echo "🔹 Installing in $dir..."
(cd "$dir" && npm install)
fi
done
# 4. Environment Configuration # 4. Environment Configuration
echo "" echo ""
echo "⚙️ Configuring environment..."
if [ ! -f ".env" ]; then if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then if [ -f ".env.example" ]; then
echo "📄 Creating .env from .env.example..."
cp .env.example .env cp .env.example .env
else else
echo "📄 Creating a new .env file..."
touch .env touch .env
fi fi
fi fi
# Function to update or add a variable in .env
update_env() { update_env() {
local key=$1 local key=$1
local default_value=$2 local val=$2
local prompt_text=$3
# Read current value if it exists
local current_value=$(grep "^${key}=" .env | cut -d'=' -f2- || echo "")
local val=${current_value:-$default_value}
read -p "$prompt_text [$val]: " user_val
user_val=${user_val:-$val}
if grep -q "^${key}=" .env; then if grep -q "^${key}=" .env; then
# Use a temporary file for sed to be safe sed -i "s|^${key}=.*|${key}=${val}|" .env
sed -i "s|^${key}=.*|${key}=${user_val}|" .env
else else
echo "${key}=${user_val}" >> .env echo "${key}=${val}" >> .env
fi fi
} }
echo "Please provide the following configuration values (Press Enter for default):" # Auto-configure AI variables based on hardware
update_env "DATABASE_URL" "postgresql://user:password@localhost:5432/openccb" "Master Database URL" if [ "$HAS_NVIDIA" = true ]; then
update_env "CMS_DATABASE_URL" "postgresql://user:password@localhost:5432/openccb_cms" "CMS Database URL" update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cuda"
update_env "LMS_DATABASE_URL" "postgresql://user:password@localhost:5432/openccb_lms" "LMS Database URL" update_env "WHISPER_DEVICE" "cuda"
update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" "Studio CMS API URL" update_env "LOCAL_LLM_MODEL" "llama3:8b"
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" "Experience LMS API URL" # Uncomment GPU deploy section in docker-compose.yml while preserving indentation
sed -i '/deploy:/s/# //' docker-compose.yml
echo "" sed -i '/resources:/s/# //' docker-compose.yml
echo "🛠️ AI Configuration..." sed -i '/reservations:/s/# //' docker-compose.yml
update_env "AI_PROVIDER" "local" "AI Provider (openai | local)" sed -i '/devices:/s/# //' docker-compose.yml
if [ "$(grep "^AI_PROVIDER=" .env | cut -d'=' -f2)" == "local" ]; then sed -i '/- driver: nvidia/s/# //' docker-compose.yml
update_env "LOCAL_OLLAMA_URL" "http://localhost:11434" "Local Ollama API URL" sed -i '/count: 1/s/# //' docker-compose.yml
update_env "LOCAL_WHISPER_URL" "http://localhost:8000" "Local Whisper API URL" sed -i '/capabilities: \[ gpu \]/s/# //' docker-compose.yml
default_model="phi3:mini"
if [ "$HAS_NVIDIA" = true ]; then
default_model="llama3:8b"
fi
update_env "LOCAL_LLM_MODEL" "$default_model" "Local LLM Model"
else else
update_env "OPENAI_API_KEY" "" "OpenAI API Key" update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cpu"
update_env "WHISPER_DEVICE" "cpu"
update_env "LOCAL_LLM_MODEL" "phi3:mini"
# Ensure it's commented (if it was previously uncommented)
# (Simple approach: we leave it as is or explicitly comment it out)
fi fi
echo "✅ .env configuration updated." # Ask for DB credentials if not set
if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then
read -p "Enter Database Password [password]: " DB_PASS
DB_PASS=${DB_PASS:-password}
update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb"
update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms"
update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms"
fi
# 5. Database Initialization # 5. AI Stack Setup
if ! command -v ollama &> /dev/null; then
curl -fsSL https://ollama.com/install.sh | sh
fi
echo "⏳ Starting Ollama & downloading models..."
# Run ollama in background if not running (simple check)
if ! pgrep ollama &> /dev/null; then
ollama serve &
sleep 5
fi
until curl -s http://localhost:11434/api/tags &> /dev/null; do sleep 2; done
if [ "$HAS_NVIDIA" = true ]; then
ollama pull llama3:8b
else
ollama pull phi3:mini
fi
# 6. Database Initialization (Integrated db-mgmt.sh)
echo "" echo ""
echo "🐘 Starting database with Docker..." echo "🐘 Starting database with Docker..."
docker compose up -d db docker compose up -d db
sleep 5 # Simple wait
echo "⏳ Waiting for database to be ready..." CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2)
# Better wait using pg_isready if available LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2)
if command -v pg_isready &> /dev/null; then
until pg_isready -h localhost -p 5432 -U user; do
echo "Still waiting for Postgres..."
sleep 2
done
else
sleep 10
fi
echo "🏗️ Running database setup..." echo "🏗️ Creating databases and running migrations..."
chmod +x db-mgmt.sh DATABASE_URL=$CMS_URL sqlx database create || true
./db-mgmt.sh setup DATABASE_URL=$LMS_URL sqlx database create || true
DATABASE_URL=$CMS_URL sqlx migrate run --source services/cms-service/migrations
DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations
# 6. System Initialization # 7. System Initialization (Integrated init-system.sh)
echo "" echo ""
echo "👤 Initializing system (Admin account)..." echo "👤 Creating Initial Administrator..."
chmod +x init-system.sh API_URL="http://localhost:3001"
./init-system.sh # Start the CMS service temporarily to create the user?
# Better yet, start all services with docker compose
echo "🚀 Starting services to allow admin creation..."
docker compose up -d --build
echo "⏳ Waiting for CMS API to be ready..."
START_WAIT=$SECONDS
until curl -s "$API_URL/auth/login" &> /dev/null || [ $((SECONDS - START_WAIT)) -gt 60 ]; do sleep 2; done
read -p "Admin Email [admin@example.com]: " ADMIN_EMAIL
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
read -s -p "Admin Password [password123]: " ADMIN_PASS
ADMIN_PASS=${ADMIN_PASS:-password123}
echo ""
PAYLOAD=$(jq -n \
--arg email "$ADMIN_EMAIL" \
--arg password "$ADMIN_PASS" \
--arg full_name "System Admin" \
--arg org_name "Default Organization" \
--arg role "admin" \
'{email: $email, password: $password, full_name: $full_name, organization_name: $org_name, role: $role}')
RESPONSE=$(curl -s -X POST "$API_URL/auth/register" -H "Content-Type: application/json" -d "$PAYLOAD")
if echo "$RESPONSE" | grep -q "token"; then
echo "✅ Success! Administrator created."
else
echo "⚠️ Failed to create administrator (it might already exist)."
fi
echo "" echo ""
echo "====================================================" echo "===================================================="
echo " ✨ OpenCCB Installation Complete!" echo " ✨ OpenCCB Installation Complete!"
echo "====================================================" echo "===================================================="
echo "You can now start the services using 'docker compose up' or by"
echo "running 'npm run dev' inside the frontend directories and"
echo "'cargo run' inside the service directories."
echo ""
echo "Studio: http://localhost:3000" echo "Studio: http://localhost:3000"
echo "Experience: http://localhost:3003" echo "Experience: http://localhost:3003"
echo "CMS API: http://localhost:3001"
echo "LMS API: http://localhost:3002"
echo "====================================================" echo "===================================================="
+1
View File
@@ -7,6 +7,7 @@
- [x] Frontend Initialization (Next.js Studio & Experience) - [x] Frontend Initialization (Next.js Studio & Experience)
- [x] Dockerization of all services - [x] Dockerization of all services
- [x] API Integration (Dashboard <-> CMS Service) - [x] API Integration (Dashboard <-> CMS Service)
- [x] Unified `install.sh` script with hardware detection & auto-config
## Phase 2: Core CMS Features ✅ ## Phase 2: Core CMS Features ✅
- [x] Course Outline Editor (Modules & Lessons) - [x] Course Outline Editor (Modules & Lessons)
+1
View File
@@ -123,6 +123,7 @@ pub struct User {
pub full_name: String, pub full_name: String,
pub role: String, // admin, instructor, student pub role: String, // admin, instructor, student
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
@@ -2,7 +2,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { cmsApi, Course, CourseAnalytics } from "@/lib/api"; import { cmsApi, Course, CourseAnalytics } from "@/lib/api";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { import {
@@ -83,35 +82,26 @@ export default function AnalyticsPage() {
.sort((a, b) => a.average_score - b.average_score); .sort((a, b) => a.average_score - b.average_score);
return ( return (
<div className="min-h-screen bg-gray-950 text-white pb-20"> <div className="min-h-screen bg-[#0f1115] text-white p-8">
{/* Header */} <div className="max-w-7xl mx-auto">
<header className="sticky top-0 z-50 bg-gray-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8"> {/* Header */}
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={() => router.back()} className="p-2 hover:bg-white/5 rounded-full transition-colors"> <button
<ArrowLeft className="w-5 h-5 text-gray-400" /> onClick={() => router.back()}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button> </button>
<h1 className="text-xl font-bold">{course.title} - Performance Insights</h1> <div>
<div className="bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-wider px-2 py-1 rounded border border-blue-500/30"> <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
{user?.role} View Course Analytics
</h1>
<p className="text-gray-400 mt-1">Performance insights and student progress for {course?.title}</p>
</div> </div>
</div> </div>
</div> <div className="bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-wider px-2 py-1 rounded border border-blue-500/30">
</header> {user?.role} View
<main className="max-w-7xl mx-auto px-8 mt-12 space-y-8">
<div className="flex items-center gap-4 text-sm text-gray-400">
<Link href="/" className="hover:text-white transition-colors">Courses</Link>
<span>/</span>
<span className="text-white">{course?.title}</span>
</div>
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">{course?.title}</h2>
<div className="flex items-center gap-3 mt-1">
<span className="text-gray-400 text-sm">Performance Insights</span>
</div>
</div> </div>
</div> </div>
@@ -226,7 +216,7 @@ export default function AnalyticsPage() {
</div> </div>
</div> </div>
</CourseEditorLayout> </CourseEditorLayout>
</main> </div>
</div> </div>
); );
} }
@@ -1,21 +1,20 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cmsApi, Course, Lesson } from "@/lib/api"; import { cmsApi, Course, Lesson } from "@/lib/api";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { import {
Calendar as CalendarIcon, Plus,
Calendar,
ArrowLeft,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Layout, Clock,
CheckCircle2,
BarChart2,
Settings,
AlertCircle AlertCircle
} from "lucide-react"; } from "lucide-react";
import CourseEditorLayout from "@/components/CourseEditorLayout"; import CourseEditorLayout from "@/components/CourseEditorLayout";
export default function CourseCalendarPage({ params }: { params: { id: string } }) { export default function CourseCalendarPage({ params }: { params: { id: string } }) {
const router = useRouter();
const [course, setCourse] = useState<Course | null>(null); const [course, setCourse] = useState<Course | null>(null);
const [lessons, setLessons] = useState<Lesson[]>([]); const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -56,12 +55,12 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
// Padding for first week // Padding for first week
for (let i = 0; i < firstDay; i++) { for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="h-32 border border-white/5 bg-white/2"></div>); days.push(<div key={`empty - ${i} `} className="h-32 border border-white/5 bg-white/2"></div>);
} }
// Days of month // Days of month
for (let day = 1; day <= daysInMonth; day++) { for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const dateStr = `${year} -${String(month + 1).padStart(2, '0')} -${String(day).padStart(2, '0')} `;
const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr)); const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
days.push( days.push(
@@ -71,11 +70,11 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
{dayLessons.map(lesson => ( {dayLessons.map(lesson => (
<div <div
key={lesson.id} key={lesson.id}
className={`text-[10px] p-1 rounded truncate flex items-center gap-1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' : className={`text - [10px] p - 1 rounded truncate flex items - center gap - 1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' :
lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' : lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' : lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' :
'bg-green-500/20 text-green-400 border border-green-500/30' 'bg-green-500/20 text-green-400 border border-green-500/30'
}`} } `}
> >
<span className="w-1.5 h-1.5 rounded-full bg-current"></span> <span className="w-1.5 h-1.5 rounded-full bg-current"></span>
{lesson.title} {lesson.title}
@@ -92,105 +91,108 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
const nextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1)); const nextMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
const prevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1)); const prevMonth = () => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
if (loading) return <div className="py-20 text-center">Loading calendar...</div>; if (loading) return (
<div className="min-h-screen bg-[#0f1115] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
const monthName = currentDate.toLocaleString('default', { month: 'long' }); const monthName = currentDate.toLocaleString('default', { month: 'long' });
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
return ( return (
<div className="space-y-8"> <div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="flex items-center gap-4 text-sm text-gray-400"> <div className="max-w-6xl mx-auto">
<Link href="/" className="hover:text-white transition-colors">Courses</Link> {/* Header */}
<span>/</span> <div className="flex items-center justify-between mb-12">
<span className="text-white">{course?.title}</span> <div className="flex items-center gap-4">
</div> <button
onClick={() => router.back()}
<div className="flex justify-between items-center"> className="p-2 hover:bg-white/10 rounded-full transition-colors"
<div> >
<h2 className="text-3xl font-bold">{course?.title}</h2> <ArrowLeft className="w-6 h-6" />
<div className="flex items-center gap-3 mt-1 text-gray-400 text-sm"> </button>
<CalendarIcon className="w-4 h-4" /> <div>
<span>Course Calendar</span> <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
Course Calendar
</h1>
<p className="text-gray-400 mt-1">Manage important dates and deadlines for {course?.title}</p>
</div>
</div> </div>
</div> </div>
<div className="flex gap-3">
<Link href={`/courses/${params.id}`} className="px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">
Back to Outline
</Link>
</div>
</div>
<CourseEditorLayout activeTab="calendar"> <CourseEditorLayout activeTab="calendar">
<div className="p-8"> <div className="p-8">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<h3 className="text-2xl font-black uppercase tracking-tight">{monthName} <span className="text-blue-500">{year}</span></h3> <h3 className="text-2xl font-black uppercase tracking-tight">{monthName} <span className="text-blue-500">{year}</span></h3>
<div className="flex items-center gap-2 bg-white/5 rounded-xl p-1 border border-white/10"> <div className="flex items-center gap-2 bg-white/5 rounded-xl p-1 border border-white/10">
<button onClick={prevMonth} className="p-2 hover:bg-white/10 rounded-lg transition-colors"><ChevronLeft className="w-5 h-5" /></button> <button onClick={prevMonth} className="p-2 hover:bg-white/10 rounded-lg transition-colors"><ChevronLeft className="w-5 h-5" /></button>
<button onClick={() => setCurrentDate(new Date())} className="px-3 py-1 text-xs font-bold uppercase tracking-widest hover:text-blue-400 transition-colors">Today</button> <button onClick={() => setCurrentDate(new Date())} className="px-3 py-1 text-xs font-bold uppercase tracking-widest hover:text-blue-400 transition-colors">Today</button>
<button onClick={nextMonth} className="p-2 hover:bg-white/10 rounded-lg transition-colors"><ChevronRight className="w-5 h-5" /></button> <button onClick={nextMonth} className="p-2 hover:bg-white/10 rounded-lg transition-colors"><ChevronRight className="w-5 h-5" /></button>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-red-500"></span> Exam
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-blue-500"></span> Assignment
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-purple-500"></span> Live
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-green-500"></span> Lesson
</div>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="grid grid-cols-7 border-t border-l border-white/5 rounded-xl overflow-hidden shadow-2xl overflow-hidden">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500"> {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<span className="w-2 h-2 rounded-full bg-red-500"></span> Exam <div key={day} className="bg-white/5 py-4 text-center text-xs font-black uppercase tracking-widest text-gray-500 border-r border-b border-white/5">
</div> {day}
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500"> </div>
<span className="w-2 h-2 rounded-full bg-blue-500"></span> Assignment ))}
</div> {renderCalendar()}
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-purple-500"></span> Live
</div>
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span className="w-2 h-2 rounded-full bg-green-500"></span> Lesson
</div>
</div> </div>
</div>
<div className="grid grid-cols-7 border-t border-l border-white/5 rounded-xl overflow-hidden shadow-2xl overflow-hidden"> <div className="mt-12 space-y-4">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => ( <h4 className="text-lg font-bold flex items-center gap-2">
<div key={day} className="bg-white/5 py-4 text-center text-xs font-black uppercase tracking-widest text-gray-500 border-r border-b border-white/5"> <AlertCircle className="w-5 h-5 text-blue-500" />
{day} Upcoming Deadlines
</div> </h4>
))} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{renderCalendar()} {lessons
</div> .filter(l => l.due_date && new Date(l.due_date) >= new Date())
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
<div className="mt-12 space-y-4"> .slice(0, 6)
<h4 className="text-lg font-bold flex items-center gap-2"> .map(lesson => (
<AlertCircle className="w-5 h-5 text-blue-500" /> <div key={lesson.id} className="glass p-4 border-white/5 hover:border-blue-500/30 transition-all group">
Upcoming Deadlines <div className="flex justify-between items-start">
</h4> <div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className={`text - [10px] font - black uppercase tracking - widest mb - 1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
{lessons lesson.important_date_type === 'assignment' ? 'text-blue-400' :
.filter(l => l.due_date && new Date(l.due_date) >= new Date()) 'text-green-400'
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime()) } `}>
.slice(0, 6) {lesson.important_date_type || 'Activity'}
.map(lesson => ( </div>
<div key={lesson.id} className="glass p-4 border-white/5 hover:border-blue-500/30 transition-all group"> <h5 className="font-bold group-hover:text-blue-400 transition-colors">{lesson.title}</h5>
<div className="flex justify-between items-start"> </div>
<div> <div className="text-right">
<div className={`text-[10px] font-black uppercase tracking-widest mb-1 ${lesson.important_date_type === 'exam' ? 'text-red-400' : <div className="text-sm font-black">{new Date(lesson.due_date!).toLocaleDateString()}</div>
lesson.important_date_type === 'assignment' ? 'text-blue-400' : <div className="text-[10px] text-gray-500 uppercase font-bold">Due Date</div>
'text-green-400'
}`}>
{lesson.important_date_type || 'Activity'}
</div> </div>
<h5 className="font-bold group-hover:text-blue-400 transition-colors">{lesson.title}</h5>
</div>
<div className="text-right">
<div className="text-sm font-black">{new Date(lesson.due_date!).toLocaleDateString()}</div>
<div className="text-[10px] text-gray-500 uppercase font-bold">Due Date</div>
</div> </div>
</div> </div>
</div> ))
)) }
} </div>
</div> </div>
</div> </div>
</div> </CourseEditorLayout>
</CourseEditorLayout> </div>
</div> </div>
); );
} }
@@ -11,12 +11,8 @@ import {
CheckCircle2, CheckCircle2,
ArrowLeft, ArrowLeft,
TrendingUp, TrendingUp,
Settings, Settings
Layout,
Calendar,
BarChart2
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import CourseEditorLayout from "@/components/CourseEditorLayout"; import CourseEditorLayout from "@/components/CourseEditorLayout";
+194 -187
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cmsApi, Course, Module, Lesson } from "@/lib/api"; import { cmsApi, Course, Module, Lesson } from "@/lib/api";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
Plus, Plus,
@@ -11,14 +12,11 @@ import {
PlayCircle, PlayCircle,
FileText, FileText,
Calendar, Calendar,
CheckCircle2,
Settings,
BarChart2,
Layout,
Save, Save,
X, X,
GripVertical, GripVertical,
Trash2 Trash2,
ArrowLeft
} from "lucide-react"; } from "lucide-react";
import CourseEditorLayout from "@/components/CourseEditorLayout"; import CourseEditorLayout from "@/components/CourseEditorLayout";
@@ -27,6 +25,7 @@ interface FullModule extends Module {
} }
export default function CourseEditor({ params }: { params: { id: string } }) { export default function CourseEditor({ params }: { params: { id: string } }) {
const router = useRouter();
const [course, setCourse] = useState<Course | null>(null); const [course, setCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<FullModule[]>([]); const [modules, setModules] = useState<FullModule[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -192,202 +191,210 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
if (error) return <div className="py-20 text-center text-red-400">{error}</div>; if (error) return <div className="py-20 text-center text-red-400">{error}</div>;
return ( return (
<div className="space-y-8"> <div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="flex items-center gap-4 text-sm text-gray-400"> <div className="max-w-6xl mx-auto">
<Link href="/" className="hover:text-white transition-colors">Courses</Link> {/* Header */}
<span>/</span> <div className="flex items-center justify-between mb-12">
<span className="text-white">{course?.title}</span> <div className="flex items-center gap-4">
</div> <button
onClick={() => router.push('/')}
<div className="flex justify-between items-center"> className="p-2 hover:bg-white/10 rounded-full transition-colors"
<div> >
<h2 className="text-3xl font-bold">{course?.title}</h2> <ArrowLeft className="w-6 h-6" />
<div className="flex items-center gap-3 mt-1"> </button>
<span className="text-gray-400 text-sm">Editor - Outline</span> <div>
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded ${course?.pacing_mode === 'instructor_led' ? 'bg-purple-500/20 text-purple-400' : 'bg-green-500/20 text-green-400'}`}> <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
{course?.pacing_mode?.replace('_', ' ') || 'Self Paced'} Course Editor
</span> </h1>
<div className="flex items-center gap-3 mt-1">
<p className="text-gray-400">Design your course structure and lesson content for {course?.title}</p>
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded ${course?.pacing_mode === 'instructor_led' ? 'bg-purple-500/20 text-purple-400' : 'bg-green-500/20 text-green-400'}`}>
{course?.pacing_mode?.replace('_', ' ') || 'Self Paced'}
</span>
</div>
</div>
</div>
<div className="flex gap-3">
<button className="flex items-center gap-2 px-6 py-3 glass hover:bg-white/20 transition-all rounded-xl text-sm font-bold shadow-lg active:scale-95">
Preview
</button>
<button
onClick={handlePublish}
disabled={isPublishing}
className={`flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-95 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
>
<PlayCircle className="w-5 h-5 transition-transform group-active:scale-90" />
{isPublishing ? "Publishing..." : "Publish to LMS"}
</button>
</div> </div>
</div> </div>
<div className="flex gap-3">
<button className="flex items-center gap-2 px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">
Preview
</button>
<button
onClick={handlePublish}
disabled={isPublishing}
className={`btn-primary flex items-center gap-2 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
>
{isPublishing ? "Publishing..." : "Publish to LMS"}
</button>
</div>
</div>
<CourseEditorLayout activeTab="outline"> <CourseEditorLayout activeTab="outline">
<div className="p-8 space-y-6"> <div className="p-8 space-y-6">
{modules.map((module, mIndex) => ( {modules.map((module, mIndex) => (
<div key={module.id} className="glass rounded-xl overflow-hidden border-white/5"> <div key={module.id} className="glass rounded-xl overflow-hidden border-white/5">
<div className="bg-white/5 px-6 py-4 flex justify-between items-center border-b border-white/5"> <div className="bg-white/5 px-6 py-4 flex justify-between items-center border-b border-white/5">
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<div className="flex flex-col"> <div className="flex flex-col">
<button
onClick={() => handleReorderModule(mIndex, 'up')}
disabled={mIndex === 0}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => handleReorderModule(mIndex, 'down')}
disabled={mIndex === modules.length - 1}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
<GripVertical className="text-gray-600 w-5 h-5 cursor-grab active:cursor-grabbing" />
{editingId === module.id ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(module.id, 'module')}
className="bg-black/40 border border-blue-500/50 rounded px-3 py-1 flex-1 text-white focus:outline-none"
/>
<button onClick={() => handleSaveTitle(module.id, 'module')} className="text-green-400 hover:text-green-300">
<Save className="w-5 h-5" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-red-400">
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="flex items-center gap-3 group flex-1">
<span
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="font-semibold text-lg text-blue-400 cursor-pointer hover:text-blue-300 transition-colors"
>
{module.title || `Module ${module.position}`}
</span>
<button
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-3">
<button <button
onClick={() => handleReorderModule(mIndex, 'up')} onClick={() => handleDeleteModule(module.id)}
disabled={mIndex === 0} className="text-gray-500 hover:text-red-400 transition-colors"
className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
> >
<ChevronUp className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button>
<button
onClick={() => handleReorderModule(mIndex, 'down')}
disabled={mIndex === modules.length - 1}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
>
<ChevronDown className="w-4 h-4" />
</button> </button>
</div> </div>
<GripVertical className="text-gray-600 w-5 h-5 cursor-grab active:cursor-grabbing" />
{editingId === module.id ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(module.id, 'module')}
className="bg-black/40 border border-blue-500/50 rounded px-3 py-1 flex-1 text-white focus:outline-none"
/>
<button onClick={() => handleSaveTitle(module.id, 'module')} className="text-green-400 hover:text-green-300">
<Save className="w-5 h-5" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-red-400">
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="flex items-center gap-3 group flex-1">
<span
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="font-semibold text-lg text-blue-400 cursor-pointer hover:text-blue-300 transition-colors"
>
{module.title || `Module ${module.position}`}
</span>
<button
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
</div>
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="p-6 space-y-3">
{module.lessons.map((lesson, lIndex) => (
<div key={lesson.id} className="flex items-center gap-3 group/row">
<div className="flex flex-col opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
onClick={() => handleReorderLesson(module.id, lIndex, 'up')}
disabled={lIndex === 0}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0"
>
<ChevronUp className="w-3 h-3" />
</button>
<button
onClick={() => handleReorderLesson(module.id, lIndex, 'down')}
disabled={lIndex === module.lessons.length - 1}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0"
>
<ChevronDown className="w-3 h-3" />
</button>
</div>
<div className="flex-1">
{editingId === lesson.id ? (
<div className="flex items-center gap-2 glass border-blue-500/30 p-2 rounded-lg">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(lesson.id, 'lesson')}
className="bg-transparent border-none flex-1 text-white focus:outline-none"
/>
<button onClick={() => handleSaveTitle(lesson.id, 'lesson')} className="text-green-400">
<Save className="w-4 h-4" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center justify-between glass border-white/5 p-4 rounded-xl hover:bg-white/10 hover:border-blue-500/30 transition-all cursor-pointer group/lesson">
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} className="flex-1 flex items-center gap-4">
<div className="p-2 bg-blue-500/20 rounded-lg text-blue-400 group-hover/lesson:scale-110 transition-transform">
{lesson.content_type === 'video' ? <PlayCircle className="w-5 h-5" /> : <FileText className="w-5 h-5" />}
</div>
<div className="flex flex-col">
<span
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
className="font-medium hover:text-blue-400 transition-colors"
>
{lesson.title}
</span>
<div className="flex items-center gap-3 text-[10px] text-gray-500 uppercase mt-0.5 font-semibold">
<span>{lesson.content_type}</span>
{lesson.due_date && (
<div className="flex items-center gap-1 text-orange-400">
<Calendar className="w-3 h-3" />
{new Date(lesson.due_date).toLocaleDateString()}
</div>
)}
</div>
</div>
</Link>
<div className="flex items-center gap-4">
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
className="opacity-0 group-hover/lesson:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDeleteLesson(module.id, lesson.id); }}
className="opacity-0 group-hover/lesson:opacity-100 text-gray-500 hover:text-red-400 transition-opacity"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
))}
<button <button
onClick={() => handleDeleteModule(module.id)} onClick={() => handleAddLesson(module.id)}
className="text-gray-500 hover:text-red-400 transition-colors" className="w-full py-3 border border-dashed border-white/10 rounded-xl text-sm text-gray-500 hover:text-white hover:border-white/20 hover:bg-white/5 transition-all mt-3 flex items-center justify-center gap-2"
> >
<Trash2 className="w-4 h-4" /> <Plus className="w-4 h-4" /> New Lesson
</button> </button>
</div> </div>
</div> </div>
<div className="p-6 space-y-3"> ))}
{module.lessons.map((lesson, lIndex) => (
<div key={lesson.id} className="flex items-center gap-3 group/row">
<div className="flex flex-col opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
onClick={() => handleReorderLesson(module.id, lIndex, 'up')}
disabled={lIndex === 0}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0"
>
<ChevronUp className="w-3 h-3" />
</button>
<button
onClick={() => handleReorderLesson(module.id, lIndex, 'down')}
disabled={lIndex === module.lessons.length - 1}
className="text-gray-500 hover:text-blue-400 disabled:opacity-0"
>
<ChevronDown className="w-3 h-3" />
</button>
</div>
<div className="flex-1"> <button
{editingId === lesson.id ? ( onClick={handleAddModule}
<div className="flex items-center gap-2 glass border-blue-500/30 p-2 rounded-lg"> className="w-full py-6 border-2 border-dashed border-white/10 rounded-2xl font-medium text-gray-500 hover:text-white hover:border-white/20 hover:bg-white/5 transition-all flex items-center justify-center gap-3 text-lg"
<input >
autoFocus <Plus className="w-6 h-6" /> Add New Module
value={editValue} </button>
onChange={(e) => setEditValue(e.target.value)} </div>
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(lesson.id, 'lesson')} </CourseEditorLayout>
className="bg-transparent border-none flex-1 text-white focus:outline-none" </div>
/>
<button onClick={() => handleSaveTitle(lesson.id, 'lesson')} className="text-green-400">
<Save className="w-4 h-4" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center justify-between glass border-white/5 p-4 rounded-xl hover:bg-white/10 hover:border-blue-500/30 transition-all cursor-pointer group/lesson">
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} className="flex-1 flex items-center gap-4">
<div className="p-2 bg-blue-500/20 rounded-lg text-blue-400 group-hover/lesson:scale-110 transition-transform">
{lesson.content_type === 'video' ? <PlayCircle className="w-5 h-5" /> : <FileText className="w-5 h-5" />}
</div>
<div className="flex flex-col">
<span
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
className="font-medium hover:text-blue-400 transition-colors"
>
{lesson.title}
</span>
<div className="flex items-center gap-3 text-[10px] text-gray-500 uppercase mt-0.5 font-semibold">
<span>{lesson.content_type}</span>
{lesson.due_date && (
<div className="flex items-center gap-1 text-orange-400">
<Calendar className="w-3 h-3" />
{new Date(lesson.due_date).toLocaleDateString()}
</div>
)}
</div>
</div>
</Link>
<div className="flex items-center gap-4">
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startEditing(lesson.id, lesson.title); }}
className="opacity-0 group-hover/lesson:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDeleteLesson(module.id, lesson.id); }}
className="opacity-0 group-hover/lesson:opacity-100 text-gray-500 hover:text-red-400 transition-opacity"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
))}
<button
onClick={() => handleAddLesson(module.id)}
className="w-full py-3 border border-dashed border-white/10 rounded-xl text-sm text-gray-500 hover:text-white hover:border-white/20 hover:bg-white/5 transition-all mt-3 flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" /> New Lesson
</button>
</div>
</div>
))}
<button
onClick={handleAddModule}
className="w-full py-6 border-2 border-dashed border-white/10 rounded-2xl font-medium text-gray-500 hover:text-white hover:border-white/20 hover:bg-white/5 transition-all flex items-center justify-center gap-3 text-lg"
>
<Plus className="w-6 h-6" /> Add New Module
</button>
</div>
</CourseEditorLayout>
</div> </div>
); );
} }
+199 -206
View File
@@ -2,9 +2,8 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { cmsApi, Course } from "@/lib/api"; import { cmsApi, Course } from "@/lib/api";
import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Layout, CheckCircle2 } from "lucide-react"; import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen, Calendar, Clock } from "lucide-react";
const DEFAULT_CERTIFICATE_TEMPLATE = ` const DEFAULT_CERTIFICATE_TEMPLATE = `
<div style="width: 800px; height: 600px; padding: 40px; text-align: center; border: 10px solid #787878; font-family: 'Times New Roman', serif; background-color: #fff; color: #333;"> <div style="width: 800px; height: 600px; padding: 40px; text-align: center; border: 10px solid #787878; font-family: 'Times New Roman', serif; background-color: #fff; color: #333;">
@@ -21,6 +20,8 @@ const DEFAULT_CERTIFICATE_TEMPLATE = `
</div> </div>
`; `;
import CourseEditorLayout from "@/components/CourseEditorLayout";
export default function CourseSettingsPage() { export default function CourseSettingsPage() {
const { id } = useParams() as { id: string }; const { id } = useParams() as { id: string };
const router = useRouter(); const router = useRouter();
@@ -73,239 +74,231 @@ export default function CourseSettingsPage() {
}; };
if (loading) return ( if (loading) return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center"> <div className="min-h-screen bg-[#0f1115] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div> <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
if (!course) return ( if (!course) return (
<div className="min-h-screen bg-gray-900 text-white p-20 text-center"> <div className="min-h-screen bg-[#0f1115] text-white p-20 text-center">
Course not found. Course not found.
</div> </div>
); );
return ( return (
<div className="min-h-screen bg-gray-950 text-white pb-20"> <div className="min-h-screen bg-[#0f1115] text-white p-8">
{/* Header */} <div className="max-w-5xl mx-auto">
<header className="sticky top-0 z-50 bg-gray-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8"> {/* Header */}
<div className="max-w-5xl mx-auto flex items-center justify-between"> <div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={() => router.back()} className="p-2 hover:bg-white/5 rounded-full transition-colors"> <button
<ArrowLeft className="w-5 h-5 text-gray-400" /> onClick={() => router.back()}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button> </button>
<h1 className="text-xl font-bold">{course.title} - Settings</h1> <div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
Course Settings
</h1>
<p className="text-gray-400 mt-1">Configure general course properties and certificates for {course?.title}</p>
</div>
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-xl font-bold transition-colors disabled:opacity-50" className={`flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-95 ${saving ? "opacity-75 cursor-wait" : ""}`}
> >
<Save size={18} /> <Save size={18} />
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</button> </button>
</div> </div>
</header>
<main className="max-w-5xl mx-auto px-8 mt-12 space-y-8"> <CourseEditorLayout activeTab="settings">
<div className="glass p-1 mb-12">
<div className="flex border-b border-white/10">
<Link href={`/courses/${id}`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Layout className="w-4 h-4" /> Outline
</Link>
<Link href={`/courses/${id}/grading`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<CheckCircle2 className="w-4 h-4" /> Grading
</Link>
<Link href={`/courses/${id}/calendar`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
<Calendar className="w-4 h-4" /> Calendar
</Link>
<Link href={`/courses/${id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
<SettingsIcon className="w-4 h-4" /> Settings
</Link>
</div>
</div>
{/* Passing Percentage Section */} {/* Passing Percentage Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8"> <section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-400"> <div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-400">
<SettingsIcon size={24} /> <SettingsIcon size={24} />
</div>
<h2 className="text-2xl font-black">Grading Configuration</h2>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-gray-300 mb-3">
Passing Percentage
</label>
<div className="flex items-center gap-6">
<input
type="range"
min="0"
max="100"
value={passingPercentage}
onChange={(e) => setPassingPercentage(parseInt(e.target.value))}
className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="text-4xl font-black text-blue-400 w-24 text-right">
{passingPercentage}%
</div>
</div> </div>
<p className="text-xs text-gray-500 mt-3"> <h2 className="text-2xl font-black">Grading Configuration</h2>
Students must achieve at least this percentage to pass the course.
</p>
</div> </div>
{/* Performance Tiers Preview */} <div className="space-y-6">
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div>
<h3 className="text-sm font-bold text-gray-300 mb-4">Performance Tiers Preview</h3> <label className="block text-sm font-bold text-gray-300 mb-3">
<div className="space-y-3 text-xs"> Passing Percentage
<div className="flex items-center gap-3"> </label>
<div className="w-16 h-4 bg-red-500 rounded"></div> <div className="flex items-center gap-6">
<span className="text-red-400 font-bold">Reprobado:</span> <input
<span className="text-gray-400">0% - {Math.max(0, passingPercentage - 1)}%</span> type="range"
</div> min="0"
<div className="flex items-center gap-3"> max="100"
<div className="w-16 h-4 bg-orange-500 rounded"></div> value={passingPercentage}
<span className="text-orange-400 font-bold">Rendimiento Bajo:</span> onChange={(e) => setPassingPercentage(parseInt(e.target.value))}
<span className="text-gray-400">{passingPercentage}% - {passingPercentage + 9}%</span> className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-500"
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-yellow-500 rounded"></div>
<span className="text-yellow-400 font-bold">Rendimiento Medio:</span>
<span className="text-gray-400">{passingPercentage + 10}% - {passingPercentage + 15}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-green-500 rounded"></div>
<span className="text-green-400 font-bold">Buen Rendimiento:</span>
<span className="text-gray-400">{passingPercentage + 16}% - 90%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-blue-500 rounded"></div>
<span className="text-blue-400 font-bold">Excelente:</span>
<span className="text-gray-400">91% - 100%</span>
</div>
</div>
</div>
</div>
</section>
{/* Course Pacing Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-green-500/10 flex items-center justify-center text-green-400">
<Clock size={24} />
</div>
<h2 className="text-2xl font-black">Course Pacing & Schedule</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<label className="block text-sm font-bold text-gray-300">Pacing Mode</label>
<div className="flex gap-4">
<button
onClick={() => setPacingMode('self_paced')}
className={`flex-1 p-4 rounded-2xl border-2 transition-all text-left ${pacingMode === 'self_paced' ? 'border-blue-500 bg-blue-500/10' : 'border-white/5 bg-white/5 hover:border-white/10'}`}
>
<div className="font-bold">Self-Paced</div>
<div className="text-xs text-gray-500">Learners go at their own speed.</div>
</button>
<button
onClick={() => setPacingMode('instructor_led')}
className={`flex-1 p-4 rounded-2xl border-2 transition-all text-left ${pacingMode === 'instructor_led' ? 'border-purple-500 bg-purple-500/10' : 'border-white/5 bg-white/5 hover:border-white/10'}`}
>
<div className="font-bold">Instructor-Led</div>
<div className="text-xs text-gray-500">Cohort-based with specific dates.</div>
</button>
</div>
</div>
{pacingMode === 'instructor_led' && (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2">
<label className="block text-sm font-bold text-gray-300">Course Schedule</label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs text-gray-500">Start Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-gray-500">End Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
</div>
</section>
{/* Certificate Template Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-400">
<BookOpen size={24} />
</div>
<h2 className="text-2xl font-black">Certificate Template</h2>
</div>
<div className="space-y-6">
<p className="text-gray-400">
Design the HTML certificate that students will receive upon passing the course.
Available variables: <code className="text-blue-400">{"{{student_name}}"}</code>, <code className="text-blue-400">{"{{course_title}}"}</code>, <code className="text-blue-400">{"{{date}}"}</code>, <code className="text-blue-400">{"{{score}}"}</code>.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-2">
<label className="block text-sm font-bold text-gray-300">HTML Template</label>
<textarea
value={certificateTemplate}
onChange={(e) => setCertificateTemplate(e.target.value)}
className="w-full h-[400px] bg-black/30 border border-white/10 rounded-xl p-4 font-mono text-sm text-gray-300 focus:outline-none focus:border-blue-500 transition-colors resize-none"
placeholder="Enter HTML code here..."
/>
<button
onClick={() => setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE)}
className="text-xs text-blue-400 hover:text-blue-300 underline"
>
Reset to Default Template
</button>
</div>
<div className="space-y-2">
<label className="block text-sm font-bold text-gray-300">Live Preview</label>
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group">
<iframe
srcDoc={certificateTemplate
.replace(/{{student_name}}/g, "Jane Doe")
.replace(/{{course_title}}/g, course?.title || "Demo Course")
.replace(/{{date}}/g, new Date().toLocaleDateString())
.replace(/{{score}}/g, "95")
}
className="w-full h-full transform scale-75 origin-top-left w-[133%] h-[133%]"
style={{ border: "none" }}
/> />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 pointer-events-none transition-colors" /> <div className="text-4xl font-black text-blue-400 w-24 text-right">
{passingPercentage}%
</div>
</div>
<p className="text-xs text-gray-500 mt-3">
Students must achieve at least this percentage to pass the course.
</p>
</div>
{/* Performance Tiers Preview */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-bold text-gray-300 mb-4">Performance Tiers Preview</h3>
<div className="space-y-3 text-xs">
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-red-500 rounded"></div>
<span className="text-red-400 font-bold">Reprobado:</span>
<span className="text-gray-400">0% - {Math.max(0, passingPercentage - 1)}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-orange-500 rounded"></div>
<span className="text-orange-400 font-bold">Rendimiento Bajo:</span>
<span className="text-gray-400">{passingPercentage}% - {passingPercentage + 9}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-yellow-500 rounded"></div>
<span className="text-yellow-400 font-bold">Rendimiento Medio:</span>
<span className="text-gray-400">{passingPercentage + 10}% - {passingPercentage + 15}%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-green-500 rounded"></div>
<span className="text-green-400 font-bold">Buen Rendimiento:</span>
<span className="text-gray-400">{passingPercentage + 16}% - 90%</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-4 bg-blue-500 rounded"></div>
<span className="text-blue-400 font-bold">Excelente:</span>
<span className="text-gray-400">91% - 100%</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</section>
</main> {/* Course Pacing Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-green-500/10 flex items-center justify-center text-green-400">
<Clock size={24} />
</div>
<h2 className="text-2xl font-black">Course Pacing & Schedule</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<label className="block text-sm font-bold text-gray-300">Pacing Mode</label>
<div className="flex gap-4">
<button
onClick={() => setPacingMode('self_paced')}
className={`flex-1 p-4 rounded-2xl border-2 transition-all text-left ${pacingMode === 'self_paced' ? 'border-blue-500 bg-blue-500/10' : 'border-white/5 bg-white/5 hover:border-white/10'}`}
>
<div className="font-bold">Self-Paced</div>
<div className="text-xs text-gray-500">Learners go at their own speed.</div>
</button>
<button
onClick={() => setPacingMode('instructor_led')}
className={`flex-1 p-4 rounded-2xl border-2 transition-all text-left ${pacingMode === 'instructor_led' ? 'border-purple-500 bg-purple-500/10' : 'border-white/5 bg-white/5 hover:border-white/10'}`}
>
<div className="font-bold">Instructor-Led</div>
<div className="text-xs text-gray-500">Cohort-based with specific dates.</div>
</button>
</div>
</div>
{pacingMode === 'instructor_led' && (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2">
<label className="block text-sm font-bold text-gray-300">Course Schedule</label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs text-gray-500">Start Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-gray-500">End Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
</div>
</section>
{/* Certificate Template Section */}
<section className="bg-white/5 border border-white/10 rounded-3xl p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-400">
<BookOpen size={24} />
</div>
<h2 className="text-2xl font-black">Certificate Template</h2>
</div>
<div className="space-y-6">
<p className="text-gray-400">
Design the HTML certificate that students will receive upon passing the course.
Available variables: <code className="text-blue-400">{"{{student_name}}"}</code>, <code className="text-blue-400">{"{{course_title}}"}</code>, <code className="text-blue-400">{"{{date}}"}</code>, <code className="text-blue-400">{"{{score}}"}</code>.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-2">
<label className="block text-sm font-bold text-gray-300">HTML Template</label>
<textarea
value={certificateTemplate}
onChange={(e) => setCertificateTemplate(e.target.value)}
className="w-full h-[400px] bg-black/30 border border-white/10 rounded-xl p-4 font-mono text-sm text-gray-300 focus:outline-none focus:border-blue-500 transition-colors resize-none"
placeholder="Enter HTML code here..."
/>
<button
onClick={() => setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE)}
className="text-xs text-blue-400 hover:text-blue-300 underline"
>
Reset to Default Template
</button>
</div>
<div className="space-y-2">
<label className="block text-sm font-bold text-gray-300">Live Preview</label>
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group">
<iframe
srcDoc={certificateTemplate
.replace(/{{student_name}}/g, "Jane Doe")
.replace(/{{course_title}}/g, course?.title || "Demo Course")
.replace(/{{date}}/g, new Date().toLocaleDateString())
.replace(/{{score}}/g, "95")
}
className="w-full h-full transform scale-75 origin-top-left w-[133%] h-[133%]"
style={{ border: "none" }}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 pointer-events-none transition-colors" />
</div>
</div>
</div>
</div>
</section>
</CourseEditorLayout>
</div>
</div> </div>
); );
} }
+48 -37
View File
@@ -43,46 +43,57 @@ export default function StudioDashboard() {
}; };
return ( return (
<div className="p-8"> <div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="flex justify-between items-center mb-8"> <div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold">My Courses</h1> {/* Header */}
<button onClick={handleCreateCourse} className="btn-premium flex items-center gap-2"> <div className="flex justify-between items-center mb-12">
<Plus size={18} /> <div>
New Course <h1 className="text-4xl font-black bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent tracking-tight">
</button> My Courses
</div> </h1>
<p className="text-gray-400 mt-2">Manage and monitor your educational content</p>
</div>
<button
onClick={handleCreateCourse}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-95"
>
<Plus size={20} />
New Course
</button>
</div>
{loading ? ( {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => ( {[1, 2, 3].map(i => (
<div key={i} className="h-64 glass-card animate-pulse bg-white/5 border-white/5"></div> <div key={i} className="h-64 glass-card animate-pulse bg-white/5 border-white/5"></div>
))} ))}
</div> </div>
) : courses.length === 0 ? ( ) : courses.length === 0 ? (
<div className="text-center py-20 glass-card border-dashed border-white/10"> <div className="text-center py-20 glass-card border-dashed border-white/10">
<p className="text-gray-500">You haven&apos;t created any courses yet.</p> <p className="text-gray-500">You haven&apos;t created any courses yet.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => ( {courses.map(course => (
<Link href={`/courses/${course.id}`} key={course.id}> <Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all"> <div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
<div className="flex-1"> <div className="flex-1">
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4"> <div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
<BookOpen className="text-blue-400" /> <BookOpen className="text-blue-400" />
</div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-white/5 text-xs text-gray-500">
<span>Last updated: {new Date(course.updated_at).toLocaleDateString()}</span>
<span>ID: {course.id.slice(0, 4)}...</span>
</div> </div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
</div> </div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-white/5 text-xs text-gray-500"> </Link>
<span>Last updated: {new Date(course.updated_at).toLocaleDateString()}</span> ))}
<span>ID: {course.id.slice(0, 4)}...</span> </div>
</div> )}
</div> </div>
</Link>
))}
</div>
)}
</div> </div>
); );
} }