feat: Implement organization branding, course pacing, and display upcoming deadlines in the experience portal.
This commit is contained in:
@@ -11,3 +11,13 @@ JWT_SECRET=supersecret
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# AI Configuration
|
||||||
|
# Providers: 'openai' or 'local'
|
||||||
|
AI_PROVIDER=local
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Local AI (Ollama & Whisper)
|
||||||
|
LOCAL_WHISPER_URL=http://localhost:8000
|
||||||
|
LOCAL_OLLAMA_URL=http://localhost:11434
|
||||||
|
LOCAL_LLM_MODEL=llama3
|
||||||
|
|||||||
Generated
+2
@@ -1535,6 +1535,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
@@ -1546,6 +1547,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
|||||||
+1
-1
@@ -25,4 +25,4 @@ jsonwebtoken = "9.3"
|
|||||||
bcrypt = "0.17"
|
bcrypt = "0.17"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ OpenCCB es una infraestructura de código abierto para plataformas de gestión d
|
|||||||
|
|
||||||
## 🚀 Estado del Proyecto
|
## 🚀 Estado del Proyecto
|
||||||
|
|
||||||
El sistema se encuentra en una fase madura (**Phase 6 en progreso**), con una API robusta para la gestión de cursos, autenticación segura, multi-tenencia y análisis de datos.
|
El sistema se encuentra en una fase avanzada (**Phase 6 en progreso**), ofreciendo una infraestructura multi-inquilino de alto rendimiento, gestión de marcas por organización (branding), autenticación segura y análisis detallado de datos.
|
||||||
|
|
||||||
Consulta el archivo [ROADMAP.md](./roadmap.md) para ver el desglose detallado de funcionalidades.
|
Consulta el archivo [ROADMAP.md](./roadmap.md) para ver el desglose detallado de funcionalidades.
|
||||||
|
|
||||||
@@ -136,6 +136,32 @@ El servicio CMS expone una API RESTful en el puerto `3001`. A continuación se d
|
|||||||
- **URL**: `GET /audit-logs`
|
- **URL**: `GET /audit-logs`
|
||||||
- **Query Params**: `?page=1&limit=50`
|
- **Query Params**: `?page=1&limit=50`
|
||||||
|
|
||||||
|
### 🏢 Organizaciones & Branding
|
||||||
|
|
||||||
|
#### Listar Organizaciones (Admin)
|
||||||
|
- **URL**: `GET /organizations`
|
||||||
|
- **Descripción**: Obtiene la lista completa de inquilinos del sistema.
|
||||||
|
|
||||||
|
#### Configurar Branding
|
||||||
|
- **URL**: `PUT /organizations/{id}/branding`
|
||||||
|
- **Descripción**: Actualiza los colores primario y secundario de la organización.
|
||||||
|
- **Body (JSON)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primary_color": "#hex",
|
||||||
|
"secondary_color": "#hex"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Subir Logo de Organización
|
||||||
|
- **URL**: `POST /organizations/{id}/logo`
|
||||||
|
- **Tipo**: `multipart/form-data`
|
||||||
|
- **Campo**: `file` (Binary)
|
||||||
|
|
||||||
|
#### Obtener Branding Público
|
||||||
|
- **URL**: `GET /organizations/{id}/branding`
|
||||||
|
- **Descripción**: Recupera la identidad visual (logo y colores) de una organización.
|
||||||
|
|
||||||
## 📦 Configuración y Ejecución
|
## 📦 Configuración y Ejecución
|
||||||
|
|
||||||
1. **Variables de Entorno**:
|
1. **Variables de Entorno**:
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_LMS_API_URL: http://localhost:3002
|
NEXT_PUBLIC_LMS_API_URL: http://localhost:3002
|
||||||
|
|
||||||
|
whisper:
|
||||||
|
image: fedirz/faster-whisper-server:latest-cuda
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- whisper_cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- WHISPER_MODEL=medium
|
||||||
|
- DEVICE=cuda
|
||||||
|
# GPU support for RTX 2070 Super
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [ gpu ]
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
build:
|
build:
|
||||||
context: ./e2e
|
context: ./e2e
|
||||||
@@ -69,3 +87,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
whisper_cache:
|
||||||
|
|||||||
Executable
+244
@@ -0,0 +1,244 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# OpenCCB Unified Installation Script
|
||||||
|
# This script automates the setup of OpenCCB, including prerequisites,
|
||||||
|
# repository cloning, dependencies, and initial configuration.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_URL="https://github.com/Nurfog/openccb.git" # Example URL, should be updated if needed
|
||||||
|
PROJECT_DIR="openccb"
|
||||||
|
|
||||||
|
echo "===================================================="
|
||||||
|
echo " 🚀 Welcome to the OpenCCB Installer"
|
||||||
|
echo "===================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Detection & Cloning
|
||||||
|
if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then
|
||||||
|
echo "✅ Project detected in current directory."
|
||||||
|
PROJECT_ROOT=$(pwd)
|
||||||
|
else
|
||||||
|
echo "📂 Project not detected in current directory."
|
||||||
|
if [ -d "$PROJECT_DIR" ]; then
|
||||||
|
echo "✅ Detected project folder '$PROJECT_DIR'."
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
PROJECT_ROOT=$(pwd)
|
||||||
|
else
|
||||||
|
echo "📥 Project folder not found. Cloning from $REPO_URL..."
|
||||||
|
git clone "$REPO_URL" "$PROJECT_DIR"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
PROJECT_ROOT=$(pwd)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Prerequisite Installation
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Checking for prerequisites..."
|
||||||
|
|
||||||
|
# Function to check and install system packages (Ubuntu/Debian)
|
||||||
|
install_pkg() {
|
||||||
|
if ! command -v "$1" &> /dev/null; then
|
||||||
|
echo "🔧 Installing $1..."
|
||||||
|
sudo apt-get update && sudo apt-get install -y "$1"
|
||||||
|
else
|
||||||
|
echo "✅ $1 is already installed."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for essential tools
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
if [ -f /etc/debian_version ]; then
|
||||||
|
install_pkg "curl"
|
||||||
|
install_pkg "git"
|
||||||
|
install_pkg "jq"
|
||||||
|
install_pkg "build-essential"
|
||||||
|
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
|
||||||
|
install_pkg "docker-compose-v2"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ Unsupported Linux distribution. Please ensure curl, git, jq, docker, and docker-compose are installed."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Rust
|
||||||
|
if ! command -v cargo &> /dev/null; then
|
||||||
|
echo "🔧 Installing Rust..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
source $HOME/.cargo/env
|
||||||
|
else
|
||||||
|
echo "✅ Rust (Cargo) is already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Node.js
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "🔧 Node.js not found. Installing via NVM..."
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm install --lts
|
||||||
|
else
|
||||||
|
echo "✅ Node.js $(node -v) is already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for sqlx-cli
|
||||||
|
if ! command -v sqlx &> /dev/null; then
|
||||||
|
echo "🔧 Installing sqlx-cli..."
|
||||||
|
cargo install sqlx-cli --no-default-features --features postgres
|
||||||
|
else
|
||||||
|
echo "✅ sqlx-cli is already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# AI Stack Detection & Installation
|
||||||
|
echo ""
|
||||||
|
echo "🤖 Setting up Local AI Stack..."
|
||||||
|
|
||||||
|
HAS_NVIDIA=false
|
||||||
|
if command -v nvidia-smi &> /dev/null; then
|
||||||
|
if nvidia-smi -L &> /dev/null; then
|
||||||
|
echo "🚀 NVIDIA GPU Detected!"
|
||||||
|
HAS_NVIDIA=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
echo "✅ Ollama is already installed."
|
||||||
|
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
|
||||||
|
echo ""
|
||||||
|
echo "⚙️ Configuring environment..."
|
||||||
|
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
echo "📄 Creating .env from .env.example..."
|
||||||
|
cp .env.example .env
|
||||||
|
else
|
||||||
|
echo "📄 Creating a new .env file..."
|
||||||
|
touch .env
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to update or add a variable in .env
|
||||||
|
update_env() {
|
||||||
|
local key=$1
|
||||||
|
local default_value=$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
|
||||||
|
# Use a temporary file for sed to be safe
|
||||||
|
sed -i "s|^${key}=.*|${key}=${user_val}|" .env
|
||||||
|
else
|
||||||
|
echo "${key}=${user_val}" >> .env
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Please provide the following configuration values (Press Enter for default):"
|
||||||
|
update_env "DATABASE_URL" "postgresql://user:password@localhost:5432/openccb" "Master Database URL"
|
||||||
|
update_env "CMS_DATABASE_URL" "postgresql://user:password@localhost:5432/openccb_cms" "CMS Database URL"
|
||||||
|
update_env "LMS_DATABASE_URL" "postgresql://user:password@localhost:5432/openccb_lms" "LMS Database URL"
|
||||||
|
update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" "Studio CMS API URL"
|
||||||
|
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" "Experience LMS API URL"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🛠️ AI Configuration..."
|
||||||
|
update_env "AI_PROVIDER" "local" "AI Provider (openai | local)"
|
||||||
|
if [ "$(grep "^AI_PROVIDER=" .env | cut -d'=' -f2)" == "local" ]; then
|
||||||
|
update_env "LOCAL_OLLAMA_URL" "http://localhost:11434" "Local Ollama API URL"
|
||||||
|
update_env "LOCAL_WHISPER_URL" "http://localhost:8000" "Local Whisper API URL"
|
||||||
|
|
||||||
|
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
|
||||||
|
update_env "OPENAI_API_KEY" "" "OpenAI API Key"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ .env configuration updated."
|
||||||
|
|
||||||
|
# 5. Database Initialization
|
||||||
|
echo ""
|
||||||
|
echo "🐘 Starting database with Docker..."
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
# Better wait using pg_isready if available
|
||||||
|
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..."
|
||||||
|
chmod +x db-mgmt.sh
|
||||||
|
./db-mgmt.sh setup
|
||||||
|
|
||||||
|
# 6. System Initialization
|
||||||
|
echo ""
|
||||||
|
echo "👤 Initializing system (Admin account)..."
|
||||||
|
chmod +x init-system.sh
|
||||||
|
./init-system.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===================================================="
|
||||||
|
echo " ✨ OpenCCB Installation Complete!"
|
||||||
|
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 "Experience: http://localhost:3003"
|
||||||
|
echo "CMS API: http://localhost:3001"
|
||||||
|
echo "LMS API: http://localhost:3002"
|
||||||
|
echo "===================================================="
|
||||||
+40
-6
@@ -75,6 +75,11 @@
|
|||||||
- [x] Update Rust models & JWT Claims
|
- [x] Update Rust models & JWT Claims
|
||||||
- [x] Implement Axum middleware for organization context
|
- [x] Implement Axum middleware for organization context
|
||||||
- [x] Update Frontend registration to support organizations
|
- [x] Update Frontend registration to support organizations
|
||||||
|
- [x] **Organization Branding**: Custom identity per tenant (Completed)
|
||||||
|
- [x] Logo upload & optimization
|
||||||
|
- [x] Custom color schemes (Primary/Secondary)
|
||||||
|
- [x] Dynamic Experience Portal adaptation
|
||||||
|
- [x] Live Branding Preview in Studio
|
||||||
- [ ] **Advanced Analytics**:
|
- [ ] **Advanced Analytics**:
|
||||||
- [ ] Cohort analysis
|
- [ ] Cohort analysis
|
||||||
- [ ] Retention metrics
|
- [ ] Retention metrics
|
||||||
@@ -88,10 +93,15 @@
|
|||||||
- [x] Badges and achievements (Implemented base system)
|
- [x] Badges and achievements (Implemented base system)
|
||||||
- [ ] Leaderboards
|
- [ ] Leaderboards
|
||||||
- [ ] XP and leveling system
|
- [ ] XP and leveling system
|
||||||
- [ ] **Communication**:
|
- [x] **Course Management Enhancements**:
|
||||||
- [ ] Discussion forums
|
- [x] Manual naming for modules, lessons, and activities during creation.
|
||||||
- [ ] Direct messaging
|
- [x] Drag-and-drop reordering for modules, lessons, and activities.
|
||||||
- [ ] Announcements
|
- [x] **Pacing Control**:
|
||||||
|
- [x] Self-paced mode (Evergreen).
|
||||||
|
- [x] Instructor-led mode (Cohort-based with start/end dates).
|
||||||
|
- [x] **Course Calendar**:
|
||||||
|
- [x] Management of important dates (exams, assignments, milestones).
|
||||||
|
- [ ] Automated reminders for upcoming deadlines.
|
||||||
- [ ] **Content Library**:
|
- [ ] **Content Library**:
|
||||||
- [ ] Reusable content blocks
|
- [ ] Reusable content blocks
|
||||||
- [ ] Template courses
|
- [ ] Template courses
|
||||||
@@ -105,7 +115,7 @@
|
|||||||
- [ ] **Integration Ecosystem**:
|
- [ ] **Integration Ecosystem**:
|
||||||
- [ ] LTI 1.3 support
|
- [ ] LTI 1.3 support
|
||||||
- [ ] SCORM compliance
|
- [ ] SCORM compliance
|
||||||
- [ ] Third-party integrations (Zoom, Google Meet)
|
- [ ] Third-party integrations (Zoom, Google Meet, BigBlueButton)
|
||||||
- [ ] **Mobile Apps**:
|
- [ ] **Mobile Apps**:
|
||||||
- [ ] Native iOS app
|
- [ ] Native iOS app
|
||||||
- [ ] Native Android app
|
- [ ] Native Android app
|
||||||
@@ -115,11 +125,35 @@
|
|||||||
- [ ] Screen reader optimization
|
- [ ] Screen reader optimization
|
||||||
- [ ] Keyboard navigation
|
- [ ] Keyboard navigation
|
||||||
|
|
||||||
|
## Phase 8: Future Innovations (Next Gen)
|
||||||
|
- [ ] **AI & Adaptive Learning**:
|
||||||
|
- [ ] **AI Tutor**: Real-time context-aware assistant for students.
|
||||||
|
- [ ] **Auto-grading**: LLM-based evaluation for short answers and essays.
|
||||||
|
- [ ] **Adaptive Paths**: Dynamic content unlocking based on performance.
|
||||||
|
- [ ] **Monetization & Marketplace**:
|
||||||
|
- [ ] **Multi-tenant Payments**: Integrated Stripe/Mercado Pago per organization.
|
||||||
|
- [ ] **Subscriptions**: Monthly/Yearly membership support.
|
||||||
|
- [ ] **Promotions**: Coupons, scholarships, and referral systems.
|
||||||
|
- [ ] **Social & Collaborative**:
|
||||||
|
- [ ] **Peer Review**: Structured student-to-student evaluation flows.
|
||||||
|
- [ ] **Co-working Spaces**: Real-time shared whiteboards and documents.
|
||||||
|
- [ ] **AI-Threaded Forums**: Automatic discussion summaries and sentiment analysis.
|
||||||
|
- [ ] **Enterprise Ecosystem**:
|
||||||
|
- [ ] **SSO (Single Sign-On)**: Azure AD, Okta, and Google Workspace integration.
|
||||||
|
- [ ] **HRIS Integration**: Sync with Workday, SAP, and other HR tools.
|
||||||
|
- [ ] **Webhooks & API**: Extensibility for third-party automation.
|
||||||
|
- [ ] **Deep Analytics**:
|
||||||
|
- [ ] **Dropout Prediction**: ML models to detect students at risk.
|
||||||
|
- [ ] **Engagement Heatmaps**: Detailed video and interaction tracking.
|
||||||
|
- [ ] **Offline-First Experience**:
|
||||||
|
- [ ] **Mobile Offline Mode**: Encrypted downloads for learning on the go.
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**Platform Maturity**: Core functionality is production-ready. Advanced features like AI integration are under active development.
|
**Platform Maturity**: Core functionality is production-ready. Advanced features like AI integration are under active development.
|
||||||
|
|
||||||
**Recent Milestones**:
|
**Recent Milestones**:
|
||||||
|
- ✅ **Organization Branding**: Full customization of logos and brand colors across both portals.
|
||||||
- ✅ **Multi-Tenancy**: Full support for multiple organizations, from the database to the frontend.
|
- ✅ **Multi-Tenancy**: Full support for multiple organizations, from the database to the frontend.
|
||||||
- ✅ **Holistic Grading System**: Weighted categories, attempt tracking, and dynamic passing thresholds.
|
- ✅ **Holistic Grading System**: Weighted categories, attempt tracking, and dynamic passing thresholds.
|
||||||
- ✅ **Analytics Dashboards**: Performance insights for both instructors and students.
|
- ✅ **Analytics Dashboards**: Performance insights for both instructors and students.
|
||||||
@@ -127,5 +161,5 @@
|
|||||||
|
|
||||||
**Next Priorities**:
|
**Next Priorities**:
|
||||||
1. **AI Integration**: Implement real-time video transcription.
|
1. **AI Integration**: Implement real-time video transcription.
|
||||||
2. **Gamification**: Introduce badges and achievements.
|
2. **Gamification**: Expand the badges and achievement system.
|
||||||
3. **Advanced Analytics**: Develop cohort analysis and retention metrics.
|
3. **Advanced Analytics**: Develop cohort analysis and retention metrics.
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
-- Migration: Add Multi-Tenancy Support (CMS)
|
|
||||||
-- Based on existing schema: users, courses, assets, audit_logs
|
|
||||||
|
|
||||||
-- 1. Create organizations table
|
|
||||||
CREATE TABLE organizations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 2. Create a default organization for existing data
|
|
||||||
INSERT INTO organizations (id, name) VALUES ('00000000-0000-0000-0000-000000000001', 'Default Organization');
|
|
||||||
|
|
||||||
-- 3. Add organization_id to tables with default value for existing rows
|
|
||||||
ALTER TABLE users ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
|
||||||
ALTER TABLE courses ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
|
||||||
ALTER TABLE assets ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
|
||||||
ALTER TABLE audit_logs ADD COLUMN organization_id UUID; -- Nullable for system logs or pre-migration logs
|
|
||||||
|
|
||||||
-- 4. Add Foreign Keys
|
|
||||||
ALTER TABLE users ADD CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE courses ADD CONSTRAINT fk_course_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE assets ADD CONSTRAINT fk_asset_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
-- 5. Remove default values for future inserts (enforce explicit organization)
|
|
||||||
ALTER TABLE users ALTER COLUMN organization_id DROP DEFAULT;
|
|
||||||
ALTER TABLE courses ALTER COLUMN organization_id DROP DEFAULT;
|
|
||||||
ALTER TABLE assets ALTER COLUMN organization_id DROP DEFAULT;
|
|
||||||
|
|
||||||
-- 6. Update Unique Constraints for Users
|
|
||||||
-- Drop the global unique email constraint (created implicitly by UNIQUE in 20231219000003_users_table.sql)
|
|
||||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
|
|
||||||
|
|
||||||
-- Add composite unique index scoped to organization
|
|
||||||
CREATE UNIQUE INDEX users_organization_id_email_idx ON users (organization_id, lower(email));
|
|
||||||
|
|
||||||
-- 7. Update Audit Logs to backfill organization based on user (optional best effort)
|
|
||||||
UPDATE audit_logs SET organization_id = u.organization_id FROM users u WHERE audit_logs.user_id = u.id;
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Migration: Add Multi-Tenancy Support (CMS)
|
||||||
|
-- Based on existing schema: users, courses, assets, audit_logs
|
||||||
|
|
||||||
|
-- 1. Create organizations table
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Create a default organization for existing data
|
||||||
|
INSERT INTO organizations (id, name) VALUES ('00000000-0000-0000-0000-000000000001', 'Default Organization');
|
||||||
|
|
||||||
|
-- 3. Add organization_id to tables with default value for existing rows
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE courses ADD COLUMN IF NOT EXISTS organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS organization_id UUID; -- Nullable for system logs or pre-migration logs
|
||||||
|
|
||||||
|
-- 4. Add Foreign Keys (wrapped in DO block for safety)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_user_organization') THEN
|
||||||
|
ALTER TABLE users ADD CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_course_organization') THEN
|
||||||
|
ALTER TABLE courses ADD CONSTRAINT fk_course_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_asset_organization') THEN
|
||||||
|
ALTER TABLE assets ADD CONSTRAINT fk_asset_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_organization') THEN
|
||||||
|
ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 5. Remove default values for future inserts (enforce explicit organization)
|
||||||
|
ALTER TABLE users ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
ALTER TABLE courses ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
ALTER TABLE assets ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
|
||||||
|
-- 6. Update Unique Constraints for Users
|
||||||
|
-- Drop the global unique email constraint (created implicitly by UNIQUE in 20231219000003_users_table.sql)
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
|
||||||
|
|
||||||
|
-- Add composite unique index scoped to organization
|
||||||
|
CREATE UNIQUE INDEX users_organization_id_email_idx ON users (organization_id, lower(email));
|
||||||
|
|
||||||
|
-- 7. Update Audit Logs to backfill organization based on user (optional best effort)
|
||||||
|
UPDATE audit_logs SET organization_id = u.organization_id FROM users u WHERE audit_logs.user_id = u.id;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Phase 5: Course Pacing and Dates
|
||||||
|
|
||||||
|
-- Add pacing_mode to courses
|
||||||
|
ALTER TABLE courses ADD COLUMN IF NOT EXISTS pacing_mode VARCHAR(50) NOT NULL DEFAULT 'self_paced';
|
||||||
|
|
||||||
|
-- Add due_date and important_date_type to lessons
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS important_date_type VARCHAR(50);
|
||||||
@@ -4,6 +4,7 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use common::auth::create_jwt;
|
use common::auth::create_jwt;
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
use common::models::{
|
use common::models::{
|
||||||
@@ -13,6 +14,7 @@ use common::models::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::env;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn publish_course(
|
pub async fn publish_course(
|
||||||
@@ -120,15 +122,24 @@ pub async fn create_course(
|
|||||||
.ok_or(StatusCode::BAD_REQUEST)?;
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
let instructor_id = claims.sub;
|
let instructor_id = claims.sub;
|
||||||
|
|
||||||
|
let pacing_mode = payload
|
||||||
|
.get("pacing_mode")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("self_paced");
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
let course = sqlx::query_as::<_, Course>(
|
||||||
"INSERT INTO courses (title, instructor_id, organization_id) VALUES ($1, $2, $3) RETURNING *"
|
"INSERT INTO courses (title, instructor_id, organization_id, pacing_mode) VALUES ($1, $2, $3, $4) RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(instructor_id)
|
.bind(instructor_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
|
.bind(pacing_mode)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Create course failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
@@ -136,7 +147,7 @@ pub async fn create_course(
|
|||||||
"CREATE",
|
"CREATE",
|
||||||
"Course",
|
"Course",
|
||||||
course.id,
|
course.id,
|
||||||
json!({ "title": title }),
|
json!({ "title": title, "pacing_mode": pacing_mode }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -162,7 +173,6 @@ pub async fn update_course(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||||
// 1. Fetch course and check ownership/role
|
|
||||||
let existing =
|
let existing =
|
||||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -175,7 +185,6 @@ pub async fn update_course(
|
|||||||
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update fields
|
|
||||||
let title = payload
|
let title = payload
|
||||||
.get("title")
|
.get("title")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -189,22 +198,39 @@ pub async fn update_course(
|
|||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.unwrap_or(existing.passing_percentage as i64) as i32;
|
.unwrap_or(existing.passing_percentage as i64) as i32;
|
||||||
|
|
||||||
// Check if certificate_template is in payload (even if null to unset?)
|
|
||||||
// For simplicity: if provided as string, use it. If not provided, keep existing.
|
|
||||||
// To unset, user can send empty string maybe?
|
|
||||||
let certificate_template = payload
|
let certificate_template = payload
|
||||||
.get("certificate_template")
|
.get("certificate_template")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.or(existing.certificate_template);
|
.or(existing.certificate_template);
|
||||||
|
|
||||||
|
let pacing_mode = payload
|
||||||
|
.get("pacing_mode")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&existing.pacing_mode);
|
||||||
|
|
||||||
|
let start_date = payload
|
||||||
|
.get("start_date")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
|
.or(existing.start_date);
|
||||||
|
|
||||||
|
let end_date = payload
|
||||||
|
.get("end_date")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
|
.or(existing.end_date);
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
let course = sqlx::query_as::<_, Course>(
|
||||||
"UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, updated_at = NOW() WHERE id = $5 AND organization_id = $6 RETURNING *"
|
"UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, pacing_mode = $5, start_date = $6, end_date = $7, updated_at = NOW() WHERE id = $8 AND organization_id = $9 RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
.bind(passing_percentage)
|
.bind(passing_percentage)
|
||||||
.bind(certificate_template)
|
.bind(certificate_template)
|
||||||
|
.bind(pacing_mode)
|
||||||
|
.bind(start_date)
|
||||||
|
.bind(end_date)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -299,9 +325,16 @@ pub async fn create_lesson(
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
let due_date = payload
|
||||||
|
.get("due_date")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok());
|
||||||
|
|
||||||
|
let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
|
||||||
|
|
||||||
let lesson = sqlx::query_as::<_, Lesson>(
|
let lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry)
|
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry, due_date, important_date_type)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *"
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(module_id)
|
.bind(module_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
@@ -314,9 +347,14 @@ pub async fn create_lesson(
|
|||||||
.bind(grading_category_id)
|
.bind(grading_category_id)
|
||||||
.bind(max_attempts)
|
.bind(max_attempts)
|
||||||
.bind(allow_retry)
|
.bind(allow_retry)
|
||||||
|
.bind(due_date)
|
||||||
|
.bind(important_date_type)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Create lesson failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
@@ -337,31 +375,114 @@ pub async fn process_transcription(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let _lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Lesson fetch failed: {}", e);
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
})?;
|
||||||
|
|
||||||
// 2. Simulate AI Processing
|
if lesson.content_type != "video" && lesson.content_type != "audio" {
|
||||||
let mock_transcription = json!({
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
"en": "This is a simulated transcription of the video content in English.",
|
}
|
||||||
"es": "Esta es una transcripción simulada del contenido del video en español.",
|
|
||||||
"cues": [
|
let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
{ "start": 0.0, "end": 2.0, "text": "Hello world!" },
|
let filename = url.trim_start_matches("/assets/");
|
||||||
{ "start": 2.1, "end": 5.0, "text": "Welcome to OpenCCB." }
|
let file_path = format!("uploads/{}", filename);
|
||||||
]
|
|
||||||
|
// 2. Read file
|
||||||
|
let file_data = tokio::fs::read(&file_path).await.map_err(|e| {
|
||||||
|
tracing::error!("File read failed ({}): {}", file_path, e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 3. Configuration
|
||||||
|
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let (url, auth_header, model) = if provider == "local" {
|
||||||
|
let base_url =
|
||||||
|
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||||
|
(
|
||||||
|
format!("{}/v1/audio/transcriptions", base_url),
|
||||||
|
"".to_string(),
|
||||||
|
"medium".to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
(
|
||||||
|
"https://api.openai.com/v1/audio/transcriptions".to_string(),
|
||||||
|
format!("Bearer {}", api_key),
|
||||||
|
"whisper-1".to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let part = reqwest::multipart::Part::bytes(file_data)
|
||||||
|
.file_name(filename.to_string())
|
||||||
|
.mime_str("application/octet-stream")
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let form = reqwest::multipart::Form::new()
|
||||||
|
.part("file", part)
|
||||||
|
.text("model", model)
|
||||||
|
.text("response_format", "verbose_json");
|
||||||
|
|
||||||
|
let mut request = client.post(&url).multipart(form);
|
||||||
|
if !auth_header.is_empty() {
|
||||||
|
request = request.header("Authorization", auth_header);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
tracing::error!("Transcription request failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::error!("Transcription API error: {}", err_body);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
let whisper_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
|
tracing::error!("Whisper JSON parse failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Extract text and segments (cues)
|
||||||
|
let text = whisper_data["text"].as_str().unwrap_or_default();
|
||||||
|
let segments = whisper_data["segments"].as_array();
|
||||||
|
|
||||||
|
let mut cues = Vec::new();
|
||||||
|
if let Some(segments) = segments {
|
||||||
|
for s in segments {
|
||||||
|
cues.push(json!({
|
||||||
|
"start": s["start"],
|
||||||
|
"end": s["end"],
|
||||||
|
"text": s["text"]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcription = json!({
|
||||||
|
"en": text,
|
||||||
|
"es": "", // Could add a translation step here
|
||||||
|
"cues": cues
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Update lesson
|
// 4. Update lesson
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *",
|
"UPDATE lessons SET transcription = $1 WHERE id = $2 RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(mock_transcription)
|
.bind(transcription)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database update failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
@@ -388,17 +509,84 @@ pub async fn summarize_lesson(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Simulate AI Summarization based on content
|
let transcription_text = lesson
|
||||||
// In a real scenario, this would call an LLM with the transcription or blocks content
|
.transcription
|
||||||
let mock_summary = format!(
|
.as_ref()
|
||||||
"This lesson, titled '{}', covers the fundamental concepts of the topic. It includes interactive elements designed to reinforce learning through practice and assessment.",
|
.and_then(|t| t["en"].as_str())
|
||||||
lesson.title
|
.unwrap_or("");
|
||||||
);
|
|
||||||
|
if transcription_text.is_empty() {
|
||||||
|
tracing::warn!("Cannot summarize lesson {}: No transcription found", id);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Configuration
|
||||||
|
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let (url, auth_header, model) = if provider == "local" {
|
||||||
|
let base_url =
|
||||||
|
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
|
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string());
|
||||||
|
(
|
||||||
|
format!("{}/v1/chat/completions", base_url),
|
||||||
|
"".to_string(),
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
format!("Bearer {}", api_key),
|
||||||
|
"gpt-4o".to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a professional educational assistant. Summarize the following lesson transcription into a high-quality summary suited for a course platform. Keep it concise but informative (max 150 words). Focus on the key learning objectives."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": transcription_text
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
if !auth_header.is_empty() {
|
||||||
|
request = request.header("Authorization", auth_header);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
tracing::error!("Summarization request failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::error!("Summarization API error: {}", err_body);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpt_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
|
tracing::error!("Summarization JSON parse failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let summary = gpt_data["choices"][0]["message"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
// 3. Update lesson
|
// 3. Update lesson
|
||||||
let updated_lesson =
|
let updated_lesson =
|
||||||
sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *")
|
sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *")
|
||||||
.bind(mock_summary)
|
.bind(summary)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -429,27 +617,90 @@ pub async fn generate_quiz(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Simulate AI Quiz Generation
|
let transcription_text = lesson
|
||||||
// Normally would use lesson content (transcription, blocks, etc.)
|
.transcription
|
||||||
let quiz_blocks = json!([
|
.as_ref()
|
||||||
|
.and_then(|t| t["en"].as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if transcription_text.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Cannot generate quiz for lesson {}: No transcription found",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Configuration
|
||||||
|
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let (url, auth_header, model) = if provider == "local" {
|
||||||
|
let base_url =
|
||||||
|
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
|
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string());
|
||||||
|
(
|
||||||
|
format!("{}/v1/chat/completions", base_url),
|
||||||
|
"".to_string(),
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
format!("Bearer {}", api_key),
|
||||||
|
"gpt-4o".to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
{
|
{
|
||||||
"id": Uuid::new_v4().to_string(),
|
"role": "system",
|
||||||
"type": "quiz",
|
"content": "You are an educational content designer. Generate 3 multiple-choice questions based on the lesson transcription. Return ONLY a JSON object with a field 'blocks' which is an array. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correctAnswer\": 0, \"explanation\": \"Explain why the answer is correct.\" } ] } }"
|
||||||
"title": "Automated Content Check",
|
},
|
||||||
"quiz_data": {
|
|
||||||
"questions": [
|
|
||||||
{
|
{
|
||||||
"id": "q1",
|
"role": "user",
|
||||||
"type": "multiple-choice",
|
"content": transcription_text
|
||||||
"question": format!("Based on '{}', what is the primary objective?", lesson.title),
|
|
||||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
|
||||||
"correctAnswer": 0,
|
|
||||||
"explanation": "This question was generated automatically based on the lesson title."
|
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"response_format": { "type": "json_object" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if !auth_header.is_empty() {
|
||||||
|
request = request.header("Authorization", auth_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
tracing::error!("Quiz generation request failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::error!("Quiz API error: {}", err_body);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
]);
|
|
||||||
|
let quiz_data: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let quiz_json_str = quiz_data["choices"][0]["message"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("{}");
|
||||||
|
|
||||||
|
let mut quiz_data_parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(quiz_json_str).unwrap_or(json!({}));
|
||||||
|
|
||||||
|
// Ensure we return just the blocks array as the frontend expects
|
||||||
|
let quiz_blocks = quiz_data_parsed
|
||||||
|
.get_mut("blocks")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(json!([]));
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
|
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
|
||||||
|
|
||||||
@@ -492,6 +743,7 @@ pub async fn update_lesson(
|
|||||||
.map(|v| v as i32);
|
.map(|v| v as i32);
|
||||||
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
|
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
|
||||||
let metadata = payload.get("metadata");
|
let metadata = payload.get("metadata");
|
||||||
|
let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
|
||||||
|
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"UPDATE lessons
|
"UPDATE lessons
|
||||||
@@ -504,8 +756,10 @@ pub async fn update_lesson(
|
|||||||
metadata = COALESCE($8, metadata),
|
metadata = COALESCE($8, metadata),
|
||||||
max_attempts = COALESCE($9, max_attempts),
|
max_attempts = COALESCE($9, max_attempts),
|
||||||
allow_retry = COALESCE($10, allow_retry),
|
allow_retry = COALESCE($10, allow_retry),
|
||||||
summary = COALESCE($11, summary)
|
summary = COALESCE($11, summary),
|
||||||
WHERE id = $12 RETURNING *"
|
due_date = CASE WHEN $12 = 'SET_NULL' THEN NULL WHEN $13::TIMESTAMPTZ IS NOT NULL THEN $13 ELSE due_date END,
|
||||||
|
important_date_type = COALESCE($14, important_date_type)
|
||||||
|
WHERE id = $15 RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(content_type)
|
.bind(content_type)
|
||||||
@@ -518,6 +772,9 @@ pub async fn update_lesson(
|
|||||||
.bind(max_attempts)
|
.bind(max_attempts)
|
||||||
.bind(allow_retry)
|
.bind(allow_retry)
|
||||||
.bind(payload.get("summary").and_then(|v| v.as_str()))
|
.bind(payload.get("summary").and_then(|v| v.as_str()))
|
||||||
|
.bind(if payload.get("due_date").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" })
|
||||||
|
.bind(payload.get("due_date").and_then(|v| v.as_str()).and_then(|s| s.parse::<DateTime<Utc>>().ok()))
|
||||||
|
.bind(important_date_type)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -668,6 +925,57 @@ pub async fn get_lessons(
|
|||||||
Ok(Json(lessons))
|
Ok(Json(lessons))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ReorderPayload {
|
||||||
|
pub items: Vec<ReorderItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ReorderItem {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder_modules(
|
||||||
|
_claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<ReorderPayload>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
for item in payload.items {
|
||||||
|
sqlx::query("UPDATE modules SET position = $1 WHERE id = $2")
|
||||||
|
.bind(item.position)
|
||||||
|
.bind(item.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Reorder modules failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder_lessons(
|
||||||
|
_claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<ReorderPayload>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
for item in payload.items {
|
||||||
|
sqlx::query("UPDATE lessons SET position = $1 WHERE id = $2")
|
||||||
|
.bind(item.position)
|
||||||
|
.bind(item.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Reorder lessons failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UploadResponse {
|
pub struct UploadResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -840,6 +1148,7 @@ pub async fn register(
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
full_name: user.full_name,
|
full_name: user.full_name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
organization_id: user.organization_id,
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
}))
|
}))
|
||||||
@@ -877,6 +1186,7 @@ pub async fn login(
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
full_name: user.full_name,
|
full_name: user.full_name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
organization_id: user.organization_id,
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
}))
|
}))
|
||||||
@@ -1073,3 +1383,94 @@ pub async fn update_module(
|
|||||||
|
|
||||||
Ok(Json(updated_module))
|
Ok(Json(updated_module))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_module(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
sqlx::query("DELETE FROM modules WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Delete module failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(&pool, claims.sub, "DELETE", "Module", id, json!({})).await;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_lesson(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
sqlx::query("DELETE FROM lessons WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(&pool, claims.sub, "DELETE_LESSON", "Lesson", id, json!({})).await;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
pub async fn get_all_users(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = sqlx::query_as::<_, UserResponse>(
|
||||||
|
"SELECT id, email, full_name, role, organization_id FROM users",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to fetch users: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_user(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = payload.get("role").and_then(|r| r.as_str());
|
||||||
|
let organization_id = payload
|
||||||
|
.get("organization_id")
|
||||||
|
.and_then(|o| o.as_str())
|
||||||
|
.and_then(|o| Uuid::parse_str(o).ok());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3"
|
||||||
|
)
|
||||||
|
.bind(role)
|
||||||
|
.bind(organization_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
log_action(&pool, claims.sub, "UPDATE_USER", "User", id, payload).await;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,14 +54,21 @@ async fn main() {
|
|||||||
"/modules",
|
"/modules",
|
||||||
get(handlers::get_modules).post(handlers::create_module),
|
get(handlers::get_modules).post(handlers::create_module),
|
||||||
)
|
)
|
||||||
.route("/modules/{id}", axum::routing::put(handlers::update_module))
|
.route("/modules/reorder", post(handlers::reorder_modules))
|
||||||
|
.route(
|
||||||
|
"/modules/{id}",
|
||||||
|
axum::routing::put(handlers::update_module).delete(handlers::delete_module),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/lessons",
|
"/lessons",
|
||||||
get(handlers::get_lessons).post(handlers::create_lesson),
|
get(handlers::get_lessons).post(handlers::create_lesson),
|
||||||
)
|
)
|
||||||
|
.route("/lessons/reorder", post(handlers::reorder_lessons))
|
||||||
.route(
|
.route(
|
||||||
"/lessons/{id}",
|
"/lessons/{id}",
|
||||||
get(handlers::get_lesson).put(handlers::update_lesson),
|
get(handlers::get_lesson)
|
||||||
|
.put(handlers::update_lesson)
|
||||||
|
.delete(handlers::delete_lesson),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/lessons/{id}/transcribe",
|
"/lessons/{id}/transcribe",
|
||||||
@@ -75,15 +82,17 @@ async fn main() {
|
|||||||
"/courses/{id}/grading",
|
"/courses/{id}/grading",
|
||||||
get(handlers::get_grading_categories),
|
get(handlers::get_grading_categories),
|
||||||
)
|
)
|
||||||
|
.route("/users", get(handlers::get_all_users))
|
||||||
|
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
.route("/assets/upload", post(handlers::upload_asset))
|
.route("/assets/upload", post(handlers::upload_asset))
|
||||||
.route("/organization", get(handlers::get_organization))
|
.route("/organization", get(handlers::get_organization))
|
||||||
.route(
|
.route(
|
||||||
"/organizations/:id/logo",
|
"/organizations/{id}/logo",
|
||||||
post(handlers_branding::upload_organization_logo),
|
post(handlers_branding::upload_organization_logo),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/organizations/:id/branding",
|
"/organizations/{id}/branding",
|
||||||
axum::routing::put(handlers_branding::update_organization_branding),
|
axum::routing::put(handlers_branding::update_organization_branding),
|
||||||
)
|
)
|
||||||
.route_layer(middleware::from_fn(
|
.route_layer(middleware::from_fn(
|
||||||
@@ -95,7 +104,7 @@ async fn main() {
|
|||||||
.route("/auth/register", post(handlers::register))
|
.route("/auth/register", post(handlers::register))
|
||||||
.route("/auth/login", post(handlers::login))
|
.route("/auth/login", post(handlers::login))
|
||||||
.route(
|
.route(
|
||||||
"/organizations/:id/branding",
|
"/organizations/{id}/branding",
|
||||||
get(handlers_branding::get_organization_branding),
|
get(handlers_branding::get_organization_branding),
|
||||||
)
|
)
|
||||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ CREATE TABLE enrollments (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
course_id UUID NOT NULL, -- Referenced by ID from CMS service
|
course_id UUID NOT NULL, -- Referenced by ID from CMS service
|
||||||
enroled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Note: In a real microservices scenario, courses might be synced from CMS or shared DB.
|
-- Note: In a real microservices scenario, courses might be synced from CMS or shared DB.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -9,6 +10,7 @@ pub struct Course {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub instructor_id: Uuid,
|
pub instructor_id: Uuid,
|
||||||
|
pub pacing_mode: String, // "self_paced" or "instructor_led"
|
||||||
pub start_date: Option<DateTime<Utc>>,
|
pub start_date: Option<DateTime<Utc>>,
|
||||||
pub end_date: Option<DateTime<Utc>>,
|
pub end_date: Option<DateTime<Utc>>,
|
||||||
pub passing_percentage: i32,
|
pub passing_percentage: i32,
|
||||||
@@ -41,6 +43,8 @@ pub struct Lesson {
|
|||||||
pub max_attempts: Option<i32>,
|
pub max_attempts: Option<i32>,
|
||||||
pub allow_retry: bool,
|
pub allow_retry: bool,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
|
pub due_date: Option<DateTime<Utc>>,
|
||||||
|
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +100,7 @@ pub struct Enrollment {
|
|||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub organization_id: Uuid,
|
pub organization_id: Uuid,
|
||||||
pub course_id: Uuid,
|
pub course_id: Uuid,
|
||||||
pub enroled_at: DateTime<Utc>,
|
pub enrolled_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -121,12 +125,13 @@ pub struct User {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
|
pub organization_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
@@ -192,6 +197,7 @@ mod tests {
|
|||||||
title: "Test Lesson".to_string(),
|
title: "Test Lesson".to_string(),
|
||||||
content_type: "activity".to_string(),
|
content_type: "activity".to_string(),
|
||||||
content_url: None,
|
content_url: None,
|
||||||
|
summary: None,
|
||||||
transcription: None,
|
transcription: None,
|
||||||
metadata: Some(json!({
|
metadata: Some(json!({
|
||||||
"blocks": [
|
"blocks": [
|
||||||
@@ -212,6 +218,8 @@ mod tests {
|
|||||||
max_attempts: None,
|
max_attempts: None,
|
||||||
allow_retry: true,
|
allow_retry: true,
|
||||||
position: 1,
|
position: 1,
|
||||||
|
due_date: None,
|
||||||
|
important_date_type: None,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,9 +237,11 @@ mod tests {
|
|||||||
let pub_course = PublishedCourse {
|
let pub_course = PublishedCourse {
|
||||||
course: Course {
|
course: Course {
|
||||||
id: course_id,
|
id: course_id,
|
||||||
|
organization_id: Uuid::new_v4(),
|
||||||
title: "Test Course".to_string(),
|
title: "Test Course".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
instructor_id: Uuid::new_v4(),
|
instructor_id: Uuid::new_v4(),
|
||||||
|
pacing_mode: "self_paced".to_string(),
|
||||||
start_date: None,
|
start_date: None,
|
||||||
end_date: None,
|
end_date: None,
|
||||||
passing_percentage: 70,
|
passing_percentage: 70,
|
||||||
|
|||||||
Generated
+12
@@ -521,6 +521,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -577,6 +578,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -1051,6 +1053,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1451,6 +1454,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2056,6 +2060,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -2218,6 +2223,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3514,6 +3520,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -4226,6 +4233,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -4416,6 +4424,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -4427,6 +4436,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5267,6 +5277,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5424,6 +5435,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { lmsApi, Course, Lesson, Module } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronRight as ChevronRightIcon,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function StudentCalendarPage({ params }: { params: { id: string } }) {
|
||||||
|
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
|
||||||
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const courseData = await lmsApi.getCourseOutline(params.id);
|
||||||
|
setCourse(courseData);
|
||||||
|
|
||||||
|
// Flatten lessons from modules
|
||||||
|
const allLessons: Lesson[] = [];
|
||||||
|
courseData.modules?.forEach(mod => {
|
||||||
|
mod.lessons.forEach(lesson => {
|
||||||
|
allLessons.push(lesson);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setLessons(allLessons);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load course data", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||||
|
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||||
|
|
||||||
|
const renderCalendar = () => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
const firstDay = getFirstDayOfMonth(year, month);
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Padding
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(<div key={`empty-${i}`} className="h-28 border border-white/5 bg-white/[0.01]"></div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
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 isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<div key={day} className={`h-28 border border-white/5 p-2 relative hover:bg-white/5 transition-colors group ${isToday ? 'bg-blue-500/5' : ''}`}>
|
||||||
|
<span className={`text-sm font-black ${isToday ? 'text-blue-400' : 'text-gray-600'}`}>
|
||||||
|
{day}
|
||||||
|
{isToday && <span className="ml-2 text-[8px] uppercase tracking-widest px-1.5 py-0.5 bg-blue-500 text-white rounded">Today</span>}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 space-y-1 overflow-y-auto max-h-20">
|
||||||
|
{dayLessons.map(lesson => (
|
||||||
|
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
||||||
|
<div
|
||||||
|
className={`text-[9px] p-1 rounded truncate flex items-center gap-1 mb-1 border transition-all hover:scale-[1.02] ${lesson.important_date_type === 'exam' ? 'bg-red-500/10 text-red-400 border-red-500/20' :
|
||||||
|
lesson.important_date_type === 'assignment' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20' :
|
||||||
|
'bg-green-500/10 text-green-400 border-green-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-current"></span>
|
||||||
|
{lesson.title}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => 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 animate-pulse text-gray-500 font-bold uppercase tracking-widest text-xs">Syncing your timeline...</div>;
|
||||||
|
if (!course) return <div className="text-center py-20 text-red-400">Course not found.</div>;
|
||||||
|
|
||||||
|
const monthName = currentDate.toLocaleString('default', { month: 'long' });
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-20">
|
||||||
|
<div className="mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-blue-500 font-bold text-xs uppercase tracking-widest">
|
||||||
|
<Link href={`/courses/${params.id}`} className="hover:text-white transition-colors">Outline</Link>
|
||||||
|
<ChevronRightIcon size={14} className="text-gray-600" />
|
||||||
|
<span>Timeline</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-black tracking-tight mb-2">Course <span className="text-blue-500">Timeline</span></h1>
|
||||||
|
<p className="text-gray-500 font-medium">{course.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full border border-white/5 bg-white/2 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500"></div> Exam
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full border border-white/5 bg-white/2 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500"></div> Assignment
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full border border-white/5 bg-white/2 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500"></div> Task
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="glass-card bg-white/[0.01] border-white/5 p-6 rounded-3xl overflow-hidden shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-xl font-black uppercase tracking-tight italic">{monthName} {year}</h3>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 rounded-2xl p-1 border border-white/10">
|
||||||
|
<button onClick={prevMonth} className="p-2 hover:bg-white/10 rounded-xl transition-colors"><ChevronLeft className="w-5 h-5" /></button>
|
||||||
|
<button onClick={() => setCurrentDate(new Date())} className="px-4 py-1 text-[10px] font-black uppercase tracking-widest hover:text-blue-400 transition-colors">Today</button>
|
||||||
|
<button onClick={nextMonth} className="p-2 hover:bg-white/10 rounded-xl transition-colors"><ChevronRight className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 border-t border-l border-white/5 rounded-2xl overflow-hidden">
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
|
<div key={day} className="bg-white/5 py-4 text-center text-[10px] font-black uppercase tracking-widest text-gray-600 border-r border-b border-white/5">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{renderCalendar()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="glass-card p-8 border-blue-500/20 bg-blue-500/5 rounded-3xl relative overflow-hidden">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h4 className="text-xs font-black uppercase tracking-[0.2em] text-blue-400 mb-6 flex items-center gap-2">
|
||||||
|
<AlertCircle size={14} /> Upcoming Deadlines
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{lessons
|
||||||
|
.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())
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(lesson => (
|
||||||
|
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`} className="block group">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1 flex justify-between">
|
||||||
|
<span>{lesson.important_date_type || 'Activity'}</span>
|
||||||
|
<span className="text-blue-500 font-black">{new Date(lesson.due_date!).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-sm group-hover:text-blue-400 transition-colors">{lesson.title}</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{lessons.filter(l => l.due_date && new Date(l.due_date) >= new Date()).length === 0 && (
|
||||||
|
<div className="text-xs text-gray-600 italic py-4">No upcoming deadlines. You are all caught up!</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-10 -right-10 w-40 h-40 bg-blue-500/10 blur-[60px] rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card p-8 border-white/5 bg-white/[0.01] rounded-3xl">
|
||||||
|
<h4 className="text-xs font-black uppercase tracking-[0.2em] text-gray-500 mb-6 flex items-center gap-2">
|
||||||
|
<Clock size={14} /> Course Pace
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-600">Mode</span>
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-white">{course.pacing_mode.replace('_', '-')}</span>
|
||||||
|
</div>
|
||||||
|
{course.start_date && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-600">Start Date</span>
|
||||||
|
<span className="text-xs font-black text-white">{new Date(course.start_date).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.end_date && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-600">End Date</span>
|
||||||
|
<span className="text-xs font-black text-white">{new Date(course.end_date).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { lmsApi, Course, Module } from "@/lib/api";
|
import { lmsApi, Course, Module } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, ChevronRight, PlayCircle } from "lucide-react";
|
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
|
||||||
|
|
||||||
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
||||||
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
|
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
|
||||||
@@ -45,6 +45,25 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
{courseData.description || "Master the core principles and advanced techniques in this structured curriculum. Each module is designed to provide actionable insights and hands-on experience."}
|
{courseData.description || "Master the core principles and advanced techniques in this structured curriculum. Each module is designed to provide actionable insights and hands-on experience."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-10">
|
||||||
|
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border text-xs font-bold uppercase tracking-widest ${courseData.pacing_mode === 'instructor_led' ? 'bg-purple-500/10 border-purple-500/30 text-purple-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{courseData.pacing_mode === 'instructor_led' ? <Clock size={14} /> : <Info size={14} />}
|
||||||
|
{courseData.pacing_mode === 'instructor_led' ? 'Instructor-Led' : 'Self-Paced'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courseData.pacing_mode === 'instructor_led' && (courseData.start_date || courseData.end_date) && (
|
||||||
|
<div className="flex items-center gap-4 text-xs font-bold text-gray-500 uppercase tracking-widest">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>
|
||||||
|
{courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'TBD'}
|
||||||
|
<span className="mx-2 text-gray-700">→</span>
|
||||||
|
{courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'TBD'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -60,11 +79,18 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/courses/${params.id}/progress`}>
|
<div className="flex gap-2">
|
||||||
<button className="px-8 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
|
<Link href={`/courses/${params.id}/calendar`}>
|
||||||
📊 View Progress
|
<button className="px-6 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
|
||||||
|
<Calendar size={16} /> Timeline
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={`/courses/${params.id}/progress`}>
|
||||||
|
<button className="px-8 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
|
||||||
|
📊 Progress
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,11 +124,21 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{lesson.due_date && (
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Deadline</div>
|
||||||
|
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
|
||||||
|
{new Date(lesson.due_date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<ChevronRight size={18} className="text-blue-500" />
|
<ChevronRight size={18} className="text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
--background-start-rgb: 10, 10, 20;
|
--background-start-rgb: 10, 10, 20;
|
||||||
--background-end-rgb: 0, 0, 0;
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
|
||||||
--accent-primary: #3b82f6;
|
/* Branding Defaults */
|
||||||
--accent-secondary: #8b5cf6;
|
--primary-color: #3b82f6;
|
||||||
|
--secondary-color: #8b5cf6;
|
||||||
|
|
||||||
|
--accent-primary: var(--primary-color);
|
||||||
|
--accent-secondary: var(--secondary-color);
|
||||||
--glass-bg: rgba(255, 255, 255, 0.03);
|
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||||
--glass-border: rgba(255, 255, 255, 0.08);
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
--glass-blur: blur(16px);
|
--glass-blur: blur(16px);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import { BrandingProvider, useBranding } from "@/context/BrandingContext";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -11,6 +12,36 @@ export const metadata: Metadata = {
|
|||||||
description: "Consume high-fidelity educational content with OpenCCB",
|
description: "Consume high-fidelity educational content with OpenCCB",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function AppHeader() {
|
||||||
|
const { branding } = useBranding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
|
||||||
|
{branding?.logo_url ? (
|
||||||
|
<img src={branding.logo_url} alt={branding.name} className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700">L</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col -gap-1">
|
||||||
|
<span className="font-black text-lg tracking-tighter text-white leading-none">
|
||||||
|
{branding?.name?.toUpperCase() || 'LEARN'}
|
||||||
|
</span>
|
||||||
|
{!branding && <span className="text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCE</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">Catalog</Link>
|
||||||
|
<Link href="#" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">My Learning</Link>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -19,34 +50,19 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen flex flex-col`}>
|
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen flex flex-col`}>
|
||||||
|
<BrandingProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{/* Header */}
|
<AppHeader />
|
||||||
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
|
|
||||||
<Link href="/" className="flex items-center gap-2 group">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
|
|
||||||
L
|
|
||||||
</div>
|
|
||||||
<span className="font-black text-xl tracking-tighter text-white">LEARN<span className="text-blue-500">EXPERIENCE</span></span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-8">
|
|
||||||
<Link href="/" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">Catalog</Link>
|
|
||||||
<Link href="#" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">My Learning</Link>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-12 px-6 border-t border-white/5 text-center bg-black/20">
|
<footer className="py-12 px-6 border-t border-white/5 text-center bg-black/20">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600">
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600">
|
||||||
Powered by OpenCCB © 2023. Advanced Agentic Coding.
|
Powered by OpenCCB © 2023. Advanced Agentic Coding.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</BrandingProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { lmsApi, Course } from "@/lib/api";
|
import { lmsApi, Course, Lesson } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Rocket, CheckCircle2, ArrowRight, Star } from "lucide-react";
|
import { Rocket, CheckCircle2, ArrowRight, Star, Calendar, Clock, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
export default function CatalogPage() {
|
export default function CatalogPage() {
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [enrollments, setEnrollments] = useState<string[]>([]);
|
const [enrollments, setEnrollments] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [gamification, setGamification] = useState<{ points: number, badges: any[] } | null>(null);
|
const [gamification, setGamification] = useState<{ points: number, badges: any[] } | null>(null);
|
||||||
|
const [upcomingDeadlines, setUpcomingDeadlines] = useState<{ lesson: Lesson, courseTitle: string }[]>([]);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,6 +29,24 @@ export default function CatalogPage() {
|
|||||||
|
|
||||||
const gamificationData = await lmsApi.getGamification(user.id);
|
const gamificationData = await lmsApi.getGamification(user.id);
|
||||||
setGamification(gamificationData);
|
setGamification(gamificationData);
|
||||||
|
|
||||||
|
// Fetch deadlines for enrolled courses
|
||||||
|
const deadlines: { lesson: Lesson, courseTitle: string }[] = [];
|
||||||
|
for (const enrollment of enrollmentData) {
|
||||||
|
try {
|
||||||
|
const outline = await lmsApi.getCourseOutline(enrollment.course_id);
|
||||||
|
outline.modules.forEach(mod => {
|
||||||
|
mod.lessons.forEach(l => {
|
||||||
|
if (l.due_date && new Date(l.due_date) >= new Date()) {
|
||||||
|
deadlines.push({ lesson: l, courseTitle: outline.title });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch outline for course ${enrollment.course_id}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUpcomingDeadlines(deadlines.sort((a, b) => new Date(a.lesson.due_date!).getTime() - new Date(b.lesson.due_date!).getTime()).slice(0, 3));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -115,7 +134,33 @@ export default function CatalogPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Visual Flair */}
|
{/* Visual Flair */}
|
||||||
<div className="absolute -bottom-10 -right-10 w-40 h-40 bg-blue-500/5 blur-[80px] rounded-full"></div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && upcomingDeadlines.length > 0 && (
|
||||||
|
<div className="mb-16 animate-in fade-in slide-in-from-top-4 duration-700 delay-200">
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-[0.3em] text-gray-500 mb-6 flex items-center gap-2">
|
||||||
|
<Calendar size={14} /> Upcoming Deadlines
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{upcomingDeadlines.map(({ lesson, courseTitle }) => (
|
||||||
|
<Link key={lesson.id} href={`/courses/${lesson.module_id}/lessons/${lesson.id}`} className="group">
|
||||||
|
<div className="glass-card p-6 border-blue-500/10 bg-blue-500/2 rounded-3xl hover:border-blue-500/30 transition-all">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-blue-400 group-hover:text-blue-300 transition-colors">
|
||||||
|
{lesson.important_date_type || 'Activity'}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs font-black text-white">{new Date(lesson.due_date!).toLocaleDateString()}</div>
|
||||||
|
<div className="text-[8px] font-bold text-gray-600 uppercase tracking-widest">Deadline</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-sm text-gray-200 mb-1 group-hover:text-white transition-colors line-clamp-1">{lesson.title}</h4>
|
||||||
|
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">{courseTitle}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { lmsApi, Organization } from '@/lib/api';
|
||||||
|
|
||||||
|
interface BrandingContextType {
|
||||||
|
branding: Organization | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandingContext = createContext<BrandingContextType>({
|
||||||
|
branding: null,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBranding = () => useContext(BrandingContext);
|
||||||
|
|
||||||
|
export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [branding, setBranding] = useState<Organization | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const orgId = process.env.NEXT_PUBLIC_ORG_ID || '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadBranding = async () => {
|
||||||
|
try {
|
||||||
|
const data = await lmsApi.getBranding(orgId);
|
||||||
|
setBranding(data);
|
||||||
|
|
||||||
|
// Apply CSS variables
|
||||||
|
if (data.primary_color) {
|
||||||
|
document.documentElement.style.setProperty('--primary-color', data.primary_color);
|
||||||
|
}
|
||||||
|
if (data.secondary_color) {
|
||||||
|
document.documentElement.style.setProperty('--secondary-color', data.secondary_color);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load branding', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadBranding();
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrandingContext.Provider value={{ branding, loading }}>
|
||||||
|
{children}
|
||||||
|
</BrandingContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
|
||||||
|
export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo_url?: string;
|
||||||
|
primary_color?: string;
|
||||||
|
secondary_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Course {
|
export interface Course {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,6 +16,9 @@ export interface Course {
|
|||||||
instructor_id: string;
|
instructor_id: string;
|
||||||
passing_percentage: number;
|
passing_percentage: number;
|
||||||
certificate_template?: string;
|
certificate_template?: string;
|
||||||
|
pacing_mode: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +67,8 @@ export interface Lesson {
|
|||||||
max_attempts: number | null;
|
max_attempts: number | null;
|
||||||
allow_retry: boolean;
|
allow_retry: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
|
due_date?: string;
|
||||||
|
important_date_type?: 'exam' | 'assignment' | 'milestone' | 'live-session';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,5 +191,11 @@ export const lmsApi = {
|
|||||||
const response = await fetch(`${API_BASE_URL}/users/${userId}/gamification`);
|
const response = await fetch(`${API_BASE_URL}/users/${userId}/gamification`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch gamification data');
|
if (!response.ok) throw new Error('Failed to fetch gamification data');
|
||||||
return response.json();
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBranding(orgId: string): Promise<Organization> {
|
||||||
|
const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch branding');
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+13
-1
@@ -545,6 +545,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -606,6 +607,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -1080,6 +1082,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2000,6 +2003,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -2162,6 +2166,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3445,6 +3450,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -4151,6 +4157,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -4346,6 +4353,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -4357,6 +4365,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -4417,7 +4426,8 @@
|
|||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
@@ -5229,6 +5239,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5386,6 +5397,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { cmsApi, Organization } from '@/lib/api';
|
import { cmsApi, Organization } from '@/lib/api';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck } from 'lucide-react';
|
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
export default function OrganizationsPage() {
|
export default function OrganizationsPage() {
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
@@ -11,6 +11,15 @@ export default function OrganizationsPage() {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newDomain, setNewDomain] = useState('');
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
|
||||||
|
// Branding States
|
||||||
|
const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false);
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||||
|
const [primaryColor, setPrimaryColor] = useState('#3B82F6');
|
||||||
|
const [secondaryColor, setSecondaryColor] = useState('#8B5CF6');
|
||||||
|
const [isSavingBranding, setIsSavingBranding] = useState(false);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,6 +50,51 @@ export default function OrganizationsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBranding = (org: Organization) => {
|
||||||
|
setSelectedOrg(org);
|
||||||
|
setPrimaryColor(org.primary_color || '#3B82F6');
|
||||||
|
setSecondaryColor(org.secondary_color || '#8B5CF6');
|
||||||
|
setIsBrandingModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !selectedOrg) return;
|
||||||
|
|
||||||
|
setUploadingLogo(true);
|
||||||
|
try {
|
||||||
|
const resp = await cmsApi.uploadOrganizationLogo(selectedOrg.id, file);
|
||||||
|
setSelectedOrg({ ...selectedOrg, logo_url: resp.url });
|
||||||
|
// Update in list
|
||||||
|
setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, logo_url: resp.url } : o));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload logo', error);
|
||||||
|
alert('Failed to upload logo. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrandingSave = async () => {
|
||||||
|
if (!selectedOrg) return;
|
||||||
|
|
||||||
|
setIsSavingBranding(true);
|
||||||
|
try {
|
||||||
|
await cmsApi.updateOrganizationBranding(selectedOrg.id, {
|
||||||
|
primary_color: primaryColor,
|
||||||
|
secondary_color: secondaryColor
|
||||||
|
});
|
||||||
|
// Update in list
|
||||||
|
setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, primary_color: primaryColor, secondary_color: secondaryColor } : o));
|
||||||
|
setIsBrandingModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update branding', error);
|
||||||
|
alert('Failed to update branding. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSavingBranding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||||
@@ -87,8 +141,12 @@ export default function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-4 mb-4">
|
<div className="flex items-start gap-4 mb-4">
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center">
|
||||||
|
{org.logo_url ? (
|
||||||
|
<img src={org.logo_url} alt={org.name} className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
<Building2 className="w-6 h-6" />
|
<Building2 className="w-6 h-6" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-lg">{org.name}</h3>
|
<h3 className="font-semibold text-lg">{org.name}</h3>
|
||||||
@@ -99,7 +157,12 @@ export default function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mt-6">
|
<div className="flex gap-2 mt-4 mb-2">
|
||||||
|
<div className="flex-1 h-1 rounded-full" style={{ backgroundColor: org.primary_color || '#3B82F6' }} title="Primary Color" />
|
||||||
|
<div className="flex-1 h-1 rounded-full" style={{ backgroundColor: org.secondary_color || '#8B5CF6' }} title="Secondary Color" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500 bg-black/20 p-2 rounded-lg">
|
<div className="flex items-center justify-between text-xs text-gray-500 bg-black/20 p-2 rounded-lg">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
@@ -109,16 +172,24 @@ export default function OrganizationsPage() {
|
|||||||
{org.id.split('-')[0]}...
|
{org.id.split('-')[0]}...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="w-full py-2 px-4 text-sm font-medium border border-white/5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openBranding(org)}
|
||||||
|
className="py-2 px-4 text-sm font-medium border border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10 text-blue-400 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Palette className="w-3 h-3" /> Branding
|
||||||
|
</button>
|
||||||
|
<button className="py-2 px-4 text-sm font-medium border border-white/5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||||
Details <ExternalLink className="w-3 h-3" />
|
Details <ExternalLink className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Create Organization Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-md glass border border-white/10 rounded-2xl p-8 shadow-2xl">
|
<div className="w-full max-w-md glass border border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
@@ -164,6 +235,146 @@ export default function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Branding Management Modal */}
|
||||||
|
{isBrandingModalOpen && selectedOrg && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-2xl glass border border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">Branding Management</h2>
|
||||||
|
<p className="text-sm text-gray-400">{selectedOrg.name}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setIsBrandingModalOpen(false)} className="p-2 hover:bg-white/5 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Logo Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-3 text-brand">Organization Logo</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 h-20 rounded-xl bg-black/40 border border-white/10 flex items-center justify-center overflow-hidden">
|
||||||
|
{selectedOrg.logo_url ? (
|
||||||
|
<img src={selectedOrg.logo_url} alt="Preview" className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<Building2 className="w-8 h-8 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="relative flex items-center justify-center gap-2 px-4 py-2 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 rounded-lg cursor-pointer transition-all border border-blue-500/20">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
|
||||||
|
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} disabled={uploadingLogo} />
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-2">PNG, JPG or SVG. Max 2MB.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colors */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-2">Primary Color</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-2">Secondary Color</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={secondaryColor}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={secondaryColor}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Preview */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-2">Experience Portal Preview</label>
|
||||||
|
<div className="rounded-xl border border-white/10 overflow-hidden bg-slate-900 shadow-inner">
|
||||||
|
{/* Mock Experience Header */}
|
||||||
|
<div className="h-10 px-4 flex items-center justify-between border-b border-white/5" style={{ backgroundColor: primaryColor }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden">
|
||||||
|
{selectedOrg.logo_url ? (
|
||||||
|
<img src={selectedOrg.logo_url} className="w-full h-full object-contain" />
|
||||||
|
) : <div className="w-3 h-3 bg-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="w-16 h-2 bg-white/30 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-6 h-2 bg-white/20 rounded" />
|
||||||
|
<div className="w-6 h-2 bg-white/20 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mock Experience Content */}
|
||||||
|
<div className="p-4 space-y-3 bg-[#0a0c10]">
|
||||||
|
<div className="w-2/3 h-4 bg-white/10 rounded mb-2" />
|
||||||
|
<div className="w-full h-24 bg-white/5 rounded-lg border border-white/5 p-3">
|
||||||
|
<div className="w-1/3 h-3 rounded mb-2" style={{ backgroundColor: secondaryColor }} />
|
||||||
|
<div className="w-full h-2 bg-white/5 rounded mb-1" />
|
||||||
|
<div className="w-full h-2 bg-white/5 rounded mb-1" />
|
||||||
|
<div className="w-1/2 h-2 bg-white/5 rounded" />
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<div className="px-3 py-1.5 rounded text-[8px] font-bold text-white" style={{ backgroundColor: primaryColor }}>
|
||||||
|
GET STARTED
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||||
|
<p className="text-[10px] text-blue-400 leading-relaxed">
|
||||||
|
This is a real-time preview of how the brand identity will apply to the student's learning experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBrandingModalOpen(false)}
|
||||||
|
className="flex-1 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBrandingSave}
|
||||||
|
disabled={isSavingBranding}
|
||||||
|
className="flex-2 px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSavingBranding ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
Save Branding
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cmsApi, Course, Lesson } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Layout,
|
||||||
|
CheckCircle2,
|
||||||
|
BarChart2,
|
||||||
|
Settings,
|
||||||
|
Clock,
|
||||||
|
AlertCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function CourseCalendarPage({ params }: { params: { id: string } }) {
|
||||||
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const courseData = await cmsApi.getCourseWithFullOutline(params.id);
|
||||||
|
setCourse(courseData);
|
||||||
|
|
||||||
|
// Flatten lessons from modules
|
||||||
|
const allLessons: Lesson[] = [];
|
||||||
|
courseData.modules?.forEach(mod => {
|
||||||
|
mod.lessons.forEach(lesson => {
|
||||||
|
allLessons.push(lesson);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setLessons(allLessons);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load course data", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||||
|
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||||
|
|
||||||
|
const renderCalendar = () => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
const firstDay = getFirstDayOfMonth(year, month);
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Padding for first week
|
||||||
|
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 of month
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
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));
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<div key={day} className="h-32 border border-white/5 p-2 relative hover:bg-white/5 transition-colors group">
|
||||||
|
<span className="text-sm font-bold text-gray-400">{day}</span>
|
||||||
|
<div className="mt-1 space-y-1 overflow-y-auto max-h-24">
|
||||||
|
{dayLessons.map(lesson => (
|
||||||
|
<div
|
||||||
|
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' :
|
||||||
|
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' :
|
||||||
|
'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>
|
||||||
|
{lesson.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => 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>;
|
||||||
|
|
||||||
|
const monthName = currentDate.toLocaleString('default', { month: 'long' });
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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 text-gray-400 text-sm">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span>Course Calendar</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className="glass p-1">
|
||||||
|
<div className="flex border-b border-white/10">
|
||||||
|
<Link href={`/courses/${params.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/${params.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/${params.id}/calendar`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
|
||||||
|
<CalendarIcon className="w-4 h-4" /> Calendar
|
||||||
|
</Link>
|
||||||
|
<Link href={`/courses/${params.id}/analytics`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
|
<BarChart2 className="w-4 h-4" /> Analytics
|
||||||
|
</Link>
|
||||||
|
<Link href={`/courses/${params.id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
|
<Settings className="w-4 h-4" /> Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<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>
|
||||||
|
<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={() => 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>
|
||||||
|
</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 className="grid grid-cols-7 border-t border-l border-white/5 rounded-xl overflow-hidden shadow-2xl overflow-hidden">
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
|
<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">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{renderCalendar()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 space-y-4">
|
||||||
|
<h4 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-500" />
|
||||||
|
Upcoming Deadlines
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{lessons
|
||||||
|
.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())
|
||||||
|
.slice(0, 6)
|
||||||
|
.map(lesson => (
|
||||||
|
<div key={lesson.id} className="glass p-4 border-white/5 hover:border-blue-500/30 transition-all group">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className={`text-[10px] font-black uppercase tracking-widest mb-1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
|
||||||
|
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
|
||||||
|
'text-green-400'
|
||||||
|
}`}>
|
||||||
|
{lesson.important_date_type || 'Activity'}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { cmsApi, GradingCategory } from "@/lib/api";
|
import { cmsApi, GradingCategory, Course } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -11,8 +11,12 @@ 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";
|
||||||
|
|
||||||
@@ -109,6 +113,26 @@ export default function GradingPolicyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 border-b-2 border-blue-500 bg-white/5">
|
||||||
|
<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}/analytics`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
|
<BarChart2 className="w-4 h-4" /> Analytics
|
||||||
|
</Link>
|
||||||
|
<Link href={`/courses/${id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
|
<Settings className="w-4 h-4" /> Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Categories List */}
|
{/* Categories List */}
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ import FillInTheBlanksBlock from "@/components/blocks/FillInTheBlanksBlock";
|
|||||||
import MatchingBlock from "@/components/blocks/MatchingBlock";
|
import MatchingBlock from "@/components/blocks/MatchingBlock";
|
||||||
import OrderingBlock from "@/components/blocks/OrderingBlock";
|
import OrderingBlock from "@/components/blocks/OrderingBlock";
|
||||||
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
|
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Pencil,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
Trash2,
|
||||||
|
PlayCircle,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
Settings,
|
||||||
|
Layout,
|
||||||
|
CheckCircle2
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
@@ -20,6 +34,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
// Activity State (Blocks)
|
// Activity State (Blocks)
|
||||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||||
const [summary, setSummary] = useState<string>("");
|
const [summary, setSummary] = useState<string>("");
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||||
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
|
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
|
||||||
const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false);
|
const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false);
|
||||||
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
|
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
|
||||||
@@ -27,6 +42,11 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">("");
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">("");
|
||||||
const [maxAttempts, setMaxAttempts] = useState<number | null>(null);
|
const [maxAttempts, setMaxAttempts] = useState<number | null>(null);
|
||||||
const [allowRetry, setAllowRetry] = useState(true);
|
const [allowRetry, setAllowRetry] = useState(true);
|
||||||
|
const [dueDate, setDueDate] = useState<string>("");
|
||||||
|
const [importantDateType, setImportantDateType] = useState<string>("");
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -39,6 +59,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
setSelectedCategoryId(lessonData.grading_category_id || "");
|
setSelectedCategoryId(lessonData.grading_category_id || "");
|
||||||
setMaxAttempts(lessonData.max_attempts);
|
setMaxAttempts(lessonData.max_attempts);
|
||||||
setAllowRetry(lessonData.allow_retry);
|
setAllowRetry(lessonData.allow_retry);
|
||||||
|
setDueDate(lessonData.due_date ? new Date(lessonData.due_date).toISOString().split('T')[0] : "");
|
||||||
|
setImportantDateType(lessonData.important_date_type || "");
|
||||||
|
|
||||||
if (lessonData.metadata?.blocks) {
|
if (lessonData.metadata?.blocks) {
|
||||||
setBlocks(lessonData.metadata.blocks);
|
setBlocks(lessonData.metadata.blocks);
|
||||||
@@ -64,6 +86,17 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
loadData();
|
loadData();
|
||||||
}, [params.id, params.lessonId]);
|
}, [params.id, params.lessonId]);
|
||||||
|
|
||||||
|
const handleSaveLessonTitle = async () => {
|
||||||
|
if (!lesson || !editValue) return;
|
||||||
|
try {
|
||||||
|
const updated = await cmsApi.updateLesson(lesson.id, { title: editValue });
|
||||||
|
setLesson(updated);
|
||||||
|
setEditingId(null);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to update title");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!lesson) return;
|
if (!lesson) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -74,7 +107,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
is_graded: isGraded,
|
is_graded: isGraded,
|
||||||
grading_category_id: selectedCategoryId || null,
|
grading_category_id: selectedCategoryId || null,
|
||||||
max_attempts: maxAttempts,
|
max_attempts: maxAttempts,
|
||||||
allow_retry: allowRetry
|
allow_retry: allowRetry,
|
||||||
|
due_date: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||||
|
important_date_type: (importantDateType || undefined) as any
|
||||||
});
|
});
|
||||||
setLesson(updated);
|
setLesson(updated);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
@@ -117,6 +152,19 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
setBlocks(newBlocks);
|
setBlocks(newBlocks);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTranscribe = async () => {
|
||||||
|
if (!lesson) return;
|
||||||
|
setIsTranscribing(true);
|
||||||
|
try {
|
||||||
|
const updated = await cmsApi.transcribeLesson(lesson.id);
|
||||||
|
setLesson(updated);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to transcribe video.");
|
||||||
|
} finally {
|
||||||
|
setIsTranscribing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSummarize = async () => {
|
const handleSummarize = async () => {
|
||||||
if (!lesson) return;
|
if (!lesson) return;
|
||||||
setIsGeneratingSummary(true);
|
setIsGeneratingSummary(true);
|
||||||
@@ -155,7 +203,31 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<span className="text-gray-700">/</span>
|
<span className="text-gray-700">/</span>
|
||||||
<span>Activity</span>
|
<span>Activity</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{editingId === 'lesson-title' ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveLessonTitle()}
|
||||||
|
className="text-4xl font-black bg-transparent border-b-2 border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveLessonTitle} className="text-green-400"><Save className="w-6 h-6" /></button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-gray-400"><X className="w-6 h-6" /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4 group">
|
||||||
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2>
|
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId('lesson-title'); setEditValue(lesson.title); }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white transition-opacity"
|
||||||
|
>
|
||||||
|
<Pencil className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -259,7 +331,98 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Summary Section */}
|
{editMode && (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<span className="text-blue-500">📅</span> Scheduling & Deadlines
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Set deadlines and mark important dates for this activity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Due Date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all font-bold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Date Type</span>
|
||||||
|
<select
|
||||||
|
value={importantDateType}
|
||||||
|
onChange={(e) => setImportantDateType(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
|
||||||
|
>
|
||||||
|
<option value="" className="bg-gray-900">Standard Activity</option>
|
||||||
|
<option value="exam" className="bg-gray-900">Exam</option>
|
||||||
|
<option value="assignment" className="bg-gray-900">Assignment</option>
|
||||||
|
<option value="milestone" className="bg-gray-900">Milestone</option>
|
||||||
|
<option value="live-session" className="bg-gray-900">Live Session</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Magic Section */}
|
||||||
|
{editMode && (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🪄</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold italic tracking-tight">AI Content Assistant</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 uppercase tracking-widest font-bold">Automate your content creation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{(lesson.content_type === 'video' || lesson.content_type === 'audio') && (
|
||||||
|
<button
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${lesson.transcription ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400 hover:border-blue-500/60'}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{isTranscribing ? '⏳' : '🎤'}</span>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Video/Audio</div>
|
||||||
|
<div className="font-bold">{isTranscribing ? 'Transcribing...' : lesson.transcription ? 'Update Transcript' : 'Transcribe Video'}</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSummarize}
|
||||||
|
disabled={isGeneratingSummary || !lesson.transcription}
|
||||||
|
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${summary ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400 hover:border-indigo-500/60 disabled:opacity-30 disabled:cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{isGeneratingSummary ? '⏳' : '✍️'}</span>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Summarization</div>
|
||||||
|
<div className="font-bold">{isGeneratingSummary ? 'Generating...' : summary ? 'Update Summary' : 'Generate Summary'}</div>
|
||||||
|
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateQuiz}
|
||||||
|
disabled={isGeneratingQuiz || !lesson.transcription}
|
||||||
|
className="p-6 bg-purple-500/10 border border-purple-500/30 hover:border-purple-500/60 rounded-2xl transition-all text-left flex flex-col gap-2 text-purple-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-xl">{isGeneratingQuiz ? '⏳' : '💡'}</span>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
|
||||||
|
<div className="font-bold">{isGeneratingQuiz ? 'Building...' : 'Generate Quiz'}</div>
|
||||||
|
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Summary Visualization */}
|
||||||
{(summary || editMode) && (
|
{(summary || editMode) && (
|
||||||
<div className="bg-gradient-to-br from-indigo-500/10 to-blue-500/10 border border-indigo-500/20 rounded-3xl p-8 space-y-6 animate-in fade-in duration-700">
|
<div className="bg-gradient-to-br from-indigo-500/10 to-blue-500/10 border border-indigo-500/20 rounded-3xl p-8 space-y-6 animate-in fade-in duration-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -270,15 +433,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<p className="text-xs text-gray-400 mt-1 uppercase tracking-widest font-bold">Key insights generated by intelligence</p>
|
<p className="text-xs text-gray-400 mt-1 uppercase tracking-widest font-bold">Key insights generated by intelligence</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editMode && (
|
|
||||||
<button
|
|
||||||
onClick={handleSummarize}
|
|
||||||
disabled={isGeneratingSummary}
|
|
||||||
className="px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-widest rounded-xl border border-blue-500/20 transition-all flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isGeneratingSummary ? "Generating..." : "Regenerate Summary"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
@@ -300,32 +454,33 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
{blocks.map((block, index) => (
|
{blocks.map((block, index) => (
|
||||||
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
|
<div key={block.id} className="relative group/block animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${index * 100}ms` }}>
|
||||||
{editMode && (
|
{editMode && (
|
||||||
<div className="absolute -left-12 top-0 h-full flex flex-col items-center gap-2 opacity-0 group-hover/block:opacity-100 transition-all">
|
<div className="absolute -left-16 top-0 h-full flex flex-col items-center gap-2 opacity-100 transition-all">
|
||||||
|
<span className="text-[10px] font-black text-gray-700 uppercase vertical-text mb-2">Move</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => moveBlock(index, 'up')}
|
onClick={() => moveBlock(index, 'up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="w-8 h-8 rounded-lg bg-white/5 text-gray-400 flex items-center justify-center hover:bg-blue-500 hover:text-white transition-all border border-white/10 disabled:opacity-20 disabled:cursor-not-allowed"
|
className="w-10 h-10 rounded-xl bg-white/5 text-gray-400 flex items-center justify-center hover:bg-blue-500 hover:text-white transition-all border border-white/10 disabled:opacity-20 disabled:cursor-not-allowed group-hover/block:scale-110"
|
||||||
title="Move Up"
|
title="Move Up"
|
||||||
>
|
>
|
||||||
<span className="text-xs">↑</span>
|
<ChevronUp className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => moveBlock(index, 'down')}
|
onClick={() => moveBlock(index, 'down')}
|
||||||
disabled={index === blocks.length - 1}
|
disabled={index === blocks.length - 1}
|
||||||
className="w-8 h-8 rounded-lg bg-white/5 text-gray-400 flex items-center justify-center hover:bg-blue-500 hover:text-white transition-all border border-white/10 disabled:opacity-20 disabled:cursor-not-allowed"
|
className="w-10 h-10 rounded-xl bg-white/5 text-gray-400 flex items-center justify-center hover:bg-blue-500 hover:text-white transition-all border border-white/10 disabled:opacity-20 disabled:cursor-not-allowed group-hover/block:scale-110"
|
||||||
title="Move Down"
|
title="Move Down"
|
||||||
>
|
>
|
||||||
<span className="text-xs">↓</span>
|
<ChevronDown className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="h-2"></div>
|
<div className="h-4"></div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeBlock(block.id)}
|
onClick={() => removeBlock(block.id)}
|
||||||
className="w-8 h-8 rounded-lg bg-red-500/10 text-red-400 flex items-center justify-center hover:bg-red-500 hover:text-white transition-all border border-red-500/20"
|
className="w-10 h-10 rounded-xl bg-red-500/10 text-red-400 flex items-center justify-center hover:bg-red-500 hover:text-white transition-all border border-red-500/20 group-hover/block:scale-110"
|
||||||
title="Remove Block"
|
title="Remove Block"
|
||||||
>
|
>
|
||||||
<span className="text-sm">×</span>
|
<Trash2 className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-0.5 flex-1 bg-white/5"></div>
|
<div className="w-0.5 flex-1 bg-white/5 mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,23 @@
|
|||||||
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 Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
PlayCircle,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Settings,
|
||||||
|
BarChart2,
|
||||||
|
Layout,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
Trash2
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface FullModule extends Module {
|
interface FullModule extends Module {
|
||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
@@ -13,14 +30,19 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
const [modules, setModules] = useState<FullModule[]>([]);
|
const [modules, setModules] = useState<FullModule[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
|
const startEditing = (id: string, currentTitle: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditValue(currentTitle);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Use cmsApi for consistent, typed data fetching
|
|
||||||
const data = await cmsApi.getCourseWithFullOutline(params.id);
|
const data = await cmsApi.getCourseWithFullOutline(params.id);
|
||||||
|
|
||||||
setCourse(data);
|
setCourse(data);
|
||||||
setModules(data.modules as FullModule[]);
|
setModules(data.modules as FullModule[]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -35,34 +57,120 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
const handleAddModule = async () => {
|
const handleAddModule = async () => {
|
||||||
const title = prompt("Module Title:");
|
const title = "New Module";
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newMod = await cmsApi.createModule(params.id, title, modules.length + 1);
|
const newMod = await cmsApi.createModule(params.id, title, modules.length + 1);
|
||||||
setModules([...modules, { ...newMod, lessons: [] }]);
|
const fullMod = { ...newMod, lessons: [] };
|
||||||
|
setModules([...modules, fullMod]);
|
||||||
|
setEditingId(newMod.id);
|
||||||
|
setEditValue(title);
|
||||||
} catch {
|
} catch {
|
||||||
alert("Failed to create module");
|
alert("Failed to create module");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLesson = async (moduleId: string) => {
|
const handleAddLesson = async (moduleId: string) => {
|
||||||
const title = prompt("Lesson Title:");
|
const mod = modules.find(m => m.id === moduleId);
|
||||||
if (!title) return;
|
if (!mod) return;
|
||||||
|
|
||||||
|
const title = "New Lesson";
|
||||||
try {
|
try {
|
||||||
// Default to 'video' for now as a content type
|
const newLesson = await cmsApi.createLesson(moduleId, title, "video", mod.lessons.length + 1);
|
||||||
const newLesson = await cmsApi.createLesson(moduleId, title, "video", 1);
|
setModules(modules.map(m =>
|
||||||
setModules(modules.map(mod =>
|
m.id === moduleId
|
||||||
mod.id === moduleId
|
? { ...m, lessons: [...m.lessons, newLesson] }
|
||||||
? { ...mod, lessons: [...mod.lessons, newLesson] }
|
: m
|
||||||
: mod
|
|
||||||
));
|
));
|
||||||
|
setEditingId(newLesson.id);
|
||||||
|
setEditValue(title);
|
||||||
} catch {
|
} catch {
|
||||||
alert("Failed to create lesson");
|
alert("Failed to create lesson");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveTitle = async (id: string, type: 'module' | 'lesson') => {
|
||||||
|
if (!editValue) {
|
||||||
|
setEditingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (type === 'module') {
|
||||||
|
await cmsApi.updateModule(id, { title: editValue });
|
||||||
|
setModules(modules.map(m => m.id === id ? { ...m, title: editValue } : m));
|
||||||
|
} else {
|
||||||
|
await cmsApi.updateLesson(id, { title: editValue });
|
||||||
|
setModules(modules.map(mod => ({
|
||||||
|
...mod,
|
||||||
|
lessons: mod.lessons.map(l => l.id === id ? { ...l, title: editValue } : l)
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to update title");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteModule = async (id: string) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this module and all its lessons?")) return;
|
||||||
|
try {
|
||||||
|
await cmsApi.deleteModule(id);
|
||||||
|
setModules(modules.filter(m => m.id !== id));
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete module");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLesson = async (moduleId: string, lessonId: string) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this lesson?")) return;
|
||||||
|
try {
|
||||||
|
await cmsApi.deleteLesson(lessonId);
|
||||||
|
setModules(modules.map(m =>
|
||||||
|
m.id === moduleId
|
||||||
|
? { ...m, lessons: m.lessons.filter(l => l.id !== lessonId) }
|
||||||
|
: m
|
||||||
|
));
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete lesson");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorderModule = async (index: number, direction: 'up' | 'down') => {
|
||||||
|
const newModules = [...modules];
|
||||||
|
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= newModules.length) return;
|
||||||
|
|
||||||
|
[newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]];
|
||||||
|
|
||||||
|
const items = newModules.map((m, i) => ({ id: m.id, position: i + 1 }));
|
||||||
|
setModules(newModules.map((m, i) => ({ ...m, position: i + 1 })));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cmsApi.reorderModules({ items });
|
||||||
|
} catch {
|
||||||
|
alert("Failed to save module order");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorderLesson = async (moduleId: string, lessonIndex: number, direction: 'up' | 'down') => {
|
||||||
|
const mod = modules.find(m => m.id === moduleId);
|
||||||
|
if (!mod) return;
|
||||||
|
|
||||||
|
const newLessons = [...mod.lessons];
|
||||||
|
const targetIndex = direction === 'up' ? lessonIndex - 1 : lessonIndex + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= newLessons.length) return;
|
||||||
|
|
||||||
|
[newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]];
|
||||||
|
|
||||||
|
const items = newLessons.map((l, i) => ({ id: l.id, position: i + 1 }));
|
||||||
|
setModules(modules.map(m => m.id === moduleId ? { ...m, lessons: newLessons.map((l, i) => ({ ...l, position: i + 1 })) } : m));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cmsApi.reorderLessons({ items });
|
||||||
|
} catch {
|
||||||
|
alert("Failed to save lesson order");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
@@ -73,7 +181,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
alert("Course published successfully to LMS!");
|
alert("Course published successfully to LMS!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Publish failed:", err);
|
console.error("Publish failed:", err);
|
||||||
alert("Failed to publish course. Check if LMS service is reachable.");
|
alert("Failed to publish course.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsPublishing(false);
|
setIsPublishing(false);
|
||||||
}
|
}
|
||||||
@@ -84,76 +192,206 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ... navigation ... */}
|
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||||
<Link href="/" className="hover:text-white cursor-pointer underline">Courses</Link>
|
<Link href="/" className="hover:text-white transition-colors">Courses</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-white">{course?.title}</span>
|
<span className="text-white">{course?.title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold">{course?.title}</h2>
|
<h2 className="text-3xl font-bold">{course?.title}</h2>
|
||||||
<p className="text-gray-400">Editor - Outline (ID: {params.id})</p>
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-gray-400 text-sm">Editor - Outline</span>
|
||||||
|
<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">
|
<div className="flex gap-3">
|
||||||
<button className="px-4 py-2 glass hover:bg-white/10 transition-colors text-sm font-medium">Preview</button>
|
<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
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={`btn-premium flex items-center gap-2 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
|
className={`btn-primary flex items-center gap-2 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
|
||||||
>
|
>
|
||||||
{isPublishing ? (
|
{isPublishing ? "Publishing..." : "Publish to LMS"}
|
||||||
<>
|
|
||||||
<span className="animate-spin text-lg">⏳</span>
|
|
||||||
Publishing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Publish to LMS"
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass p-1">
|
<div className="glass p-1">
|
||||||
<div className="flex border-b border-white/10">
|
<div className="flex border-b border-white/10">
|
||||||
<Link href={`/courses/${params.id}`} className="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 bg-white/5">Outline</Link>
|
<Link href={`/courses/${params.id}`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-blue-500 bg-white/5">
|
||||||
<Link href={`/courses/${params.id}/grading`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Grading</Link>
|
<Layout className="w-4 h-4" /> Outline
|
||||||
<Link href={`/courses/${params.id}/analytics`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Analytics</Link>
|
</Link>
|
||||||
<Link href={`/courses/${params.id}/settings`} className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Settings</Link>
|
<Link href={`/courses/${params.id}/grading`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
<button className="px-6 py-3 text-sm font-medium text-gray-500 hover:text-white transition-colors">Files</button>
|
<CheckCircle2 className="w-4 h-4" /> Grading
|
||||||
|
</Link>
|
||||||
|
<Link href={`/courses/${params.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/${params.id}/analytics`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
|
<BarChart2 className="w-4 h-4" /> Analytics
|
||||||
|
</Link>
|
||||||
|
<Link href={`/courses/${params.id}/settings`} className="flex items-center gap-2 px-6 py-4 text-sm font-medium text-gray-500 hover:text-white transition-colors">
|
||||||
|
<Settings className="w-4 h-4" /> Settings
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-8 space-y-6">
|
||||||
{modules.map((module) => (
|
{modules.map((module, mIndex) => (
|
||||||
<div key={module.id} className="glass overflow-hidden">
|
<div key={module.id} className="glass rounded-xl overflow-hidden border-white/5">
|
||||||
<div className="bg-white/5 px-4 py-3 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">
|
||||||
<span className="font-medium text-blue-400">Module {module.position}: {module.title}</span>
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<button className="text-xs text-gray-400 hover:text-white">Options</button>
|
<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>
|
</div>
|
||||||
<div className="p-4 space-y-2">
|
<GripVertical className="text-gray-600 w-5 h-5 cursor-grab active:cursor-grabbing" />
|
||||||
{module.lessons.map(lesson => (
|
|
||||||
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} key={lesson.id}>
|
{editingId === module.id ? (
|
||||||
<div className="glass border-white/5 p-3 flex items-center justify-between text-sm hover:bg-white/10 hover:border-blue-500/30 transition-all cursor-pointer group/lesson">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<input
|
||||||
<span className="text-blue-400 text-lg group-hover/lesson:scale-110 transition-transform">
|
autoFocus
|
||||||
{lesson.content_type === 'video' ? '🎬' : '📄'}
|
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 {module.position}: {module.title}
|
||||||
</span>
|
</span>
|
||||||
<span>{lesson.title}</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="flex items-center gap-3">
|
||||||
{lesson.transcription && <span className="text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded">CC</span>}
|
<button
|
||||||
<span className="text-xs text-gray-500 capitalize">{lesson.content_type}</span>
|
onClick={() => handleDeleteModule(module.id)}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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={() => handleAddLesson(module.id)}
|
onClick={() => handleAddLesson(module.id)}
|
||||||
className="w-full py-2 border border-dashed border-white/10 rounded-lg text-xs text-gray-500 hover:text-white hover:border-white/20 transition-all mt-2"
|
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"
|
||||||
>
|
>
|
||||||
+ New Lesson
|
<Plus className="w-4 h-4" /> New Lesson
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,9 +399,9 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleAddModule}
|
onClick={handleAddModule}
|
||||||
className="w-full py-4 border-2 border-dashed border-white/10 rounded-xl font-medium text-gray-500 hover:text-white hover:border-white/20 transition-all"
|
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"
|
||||||
>
|
>
|
||||||
+ Add Module
|
<Plus className="w-6 h-6" /> Add New Module
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
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 } from "lucide-react";
|
import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Layout, CheckCircle2 } 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;">
|
||||||
@@ -28,6 +29,9 @@ export default function CourseSettingsPage() {
|
|||||||
const [certificateTemplate, setCertificateTemplate] = useState("");
|
const [certificateTemplate, setCertificateTemplate] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pacingMode, setPacingMode] = useState<'self_paced' | 'instructor_led'>("self_paced");
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
@@ -36,6 +40,9 @@ export default function CourseSettingsPage() {
|
|||||||
setCourse(data);
|
setCourse(data);
|
||||||
setPassingPercentage(data.passing_percentage || 70);
|
setPassingPercentage(data.passing_percentage || 70);
|
||||||
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
|
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
|
||||||
|
setPacingMode(data.pacing_mode || "self_paced");
|
||||||
|
setStartDate(data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : "");
|
||||||
|
setEndDate(data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load course", err);
|
console.error("Failed to load course", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -50,7 +57,10 @@ export default function CourseSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = await cmsApi.updateCourse(id, {
|
const updated = await cmsApi.updateCourse(id, {
|
||||||
passing_percentage: passingPercentage,
|
passing_percentage: passingPercentage,
|
||||||
certificate_template: certificateTemplate
|
certificate_template: certificateTemplate,
|
||||||
|
pacing_mode: pacingMode,
|
||||||
|
start_date: startDate ? new Date(startDate).toISOString() : undefined,
|
||||||
|
end_date: endDate ? new Date(endDate).toISOString() : undefined
|
||||||
});
|
});
|
||||||
setCourse(updated);
|
setCourse(updated);
|
||||||
alert("Course settings updated successfully!");
|
alert("Course settings updated successfully!");
|
||||||
@@ -97,6 +107,23 @@ export default function CourseSettingsPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-8 mt-12 space-y-8">
|
<main className="max-w-5xl mx-auto px-8 mt-12 space-y-8">
|
||||||
|
<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">
|
||||||
@@ -163,6 +190,70 @@ export default function CourseSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Certificate Template 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">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function DescriptionBlock({ title, content, editMode, onChange }:
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function FillInTheBlanksBlock({ id, title, content, editMode, onC
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function MatchingBlock({ id, title, pairs, editMode, onChange }:
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function ShortAnswerBlock({ id, title, prompt, correctAnswers, ed
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
|
||||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title || ""}
|
value={title || ""}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export interface Course {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
instructor_id: string;
|
instructor_id: string;
|
||||||
|
pacing_mode: 'self_paced' | 'instructor_led';
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
passing_percentage: number;
|
passing_percentage: number;
|
||||||
certificate_template?: string;
|
certificate_template?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -57,26 +60,40 @@ export interface Lesson {
|
|||||||
grading_category_id: string | null;
|
grading_category_id: string | null;
|
||||||
max_attempts: number | null;
|
max_attempts: number | null;
|
||||||
allow_retry: boolean;
|
allow_retry: boolean;
|
||||||
|
due_date?: string;
|
||||||
|
important_date_type?: 'exam' | 'assignment' | 'milestone' | 'live-session';
|
||||||
summary?: string;
|
summary?: string;
|
||||||
transcription?: {
|
transcription?: {
|
||||||
en?: string;
|
en?: string;
|
||||||
es?: string;
|
es?: string;
|
||||||
cues?: { start: number; end: number; text: string }[];
|
cues?: { start: number; end: number; text: string }[];
|
||||||
} | null;
|
} | null;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Organization {
|
export interface Organization {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
domain?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
primary_color?: string;
|
||||||
|
secondary_color?: string;
|
||||||
|
certificate_template?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrandingPayload {
|
||||||
|
primary_color?: string;
|
||||||
|
secondary_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
organization_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@@ -152,6 +169,8 @@ const apiFetch = (url: string, options: RequestInit = {}) => {
|
|||||||
export const cmsApi = {
|
export const cmsApi = {
|
||||||
// Organization
|
// Organization
|
||||||
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
|
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
|
||||||
|
getOrganizations: (): Promise<Organization[]> => apiFetch('/organizations'),
|
||||||
|
createOrganization: (name: string, domain?: string): Promise<Organization> => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
@@ -171,8 +190,13 @@ export const cmsApi = {
|
|||||||
createLesson: (module_id: string, title: string, content_type: string, position: number): Promise<Lesson> => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }),
|
createLesson: (module_id: string, title: string, content_type: string, position: number): Promise<Lesson> => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }),
|
||||||
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
|
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
|
||||||
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
transcribeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/transcribe`, { method: 'POST' }),
|
||||||
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
|
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
|
||||||
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
|
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
|
||||||
|
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
|
||||||
|
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
|
||||||
|
reorderModules: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/modules/reorder', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
reorderLessons: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/lessons/reorder', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
// Grading
|
// Grading
|
||||||
getGradingCategories: (courseId: string): Promise<GradingCategory[]> => apiFetch(`/courses/${courseId}/grading`),
|
getGradingCategories: (courseId: string): Promise<GradingCategory[]> => apiFetch(`/courses/${courseId}/grading`),
|
||||||
@@ -183,6 +207,10 @@ export const cmsApi = {
|
|||||||
getAuditLogs: (): Promise<AuditLog[]> => apiFetch('/audit-logs'),
|
getAuditLogs: (): Promise<AuditLog[]> => apiFetch('/audit-logs'),
|
||||||
getCourseAnalytics: (id: string): Promise<CourseAnalytics> => apiFetch(`/courses/${id}/analytics`),
|
getCourseAnalytics: (id: string): Promise<CourseAnalytics> => apiFetch(`/courses/${id}/analytics`),
|
||||||
|
|
||||||
|
// Users
|
||||||
|
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
|
||||||
|
updateUser: (id: string, role: string, organization_id: string): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }),
|
||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
uploadAsset: (file: File): Promise<UploadResponse> => {
|
uploadAsset: (file: File): Promise<UploadResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -204,4 +232,23 @@ export const cmsApi = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// Organizations Branding
|
||||||
|
getOrganizationBranding: (id: string): Promise<Organization> => apiFetch(`/organizations/${id}/branding`),
|
||||||
|
updateOrganizationBranding: (id: string, payload: BrandingPayload): Promise<void> => apiFetch(`/organizations/${id}/branding`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
uploadOrganizationLogo: (id: string, file: File): Promise<UploadResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
return fetch(`${API_BASE_URL}/organizations/${id}/logo`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
}).then(res => {
|
||||||
|
if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Logo upload failed')));
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user