feat: Implement organization branding, course pacing, and display upcoming deadlines in the experience portal.

This commit is contained in:
2025-12-29 01:30:48 -03:00
parent 1a2b9e473c
commit 158aa5b315
41 changed files with 2422 additions and 262 deletions
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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"] }
+27 -1
View File
@@ -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**:
+19
View File
@@ -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
View File
@@ -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 "===================================================="
View File
+40 -6
View File
@@ -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);
+456 -55
View File
@@ -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())
"id": Uuid::new_v4().to_string(), .unwrap_or("");
"type": "quiz",
"title": "Automated Content Check", if transcription_text.is_empty() {
"quiz_data": { tracing::warn!(
"questions": [ "Cannot generate quiz for lesson {}: No transcription found",
{ id
"id": "q1", );
"type": "multiple-choice", return Err(StatusCode::BAD_REQUEST);
"question": format!("Based on '{}', what is the primary objective?", lesson.title), }
"options": ["Option A", "Option B", "Option C", "Option D"],
"correctAnswer": 0, // 2. Configuration
"explanation": "This question was generated automatically based on the lesson title." 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 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.\" } ] } }"
},
{
"role": "user",
"content": transcription_text
}
],
"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)
}
+14 -5
View File
@@ -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.
+12 -2
View File
@@ -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,
+12
View File
@@ -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>
);
}
+44 -8
View File
@@ -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">
</button> <Calendar size={16} /> Timeline
</Link> </button>
</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,8 +124,18 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</span> </span>
</div> </div>
</div> </div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-6">
<ChevronRight size={18} className="text-blue-500" /> {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">
<ChevronRight size={18} className="text-blue-500" />
</div>
</div> </div>
</div> </div>
</div> </div>
+6 -2
View File
@@ -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);
+44 -28
View File
@@ -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`}>
<AuthProvider> <BrandingProvider>
{/* Header */} <AuthProvider>
<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"> <AppHeader />
<Link href="/" className="flex items-center gap-2 group"> <main className="flex-1">
<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"> {children}
L </main>
</div> <footer className="py-12 px-6 border-t border-white/5 text-center bg-black/20">
<span className="font-black text-xl tracking-tighter text-white">LEARN<span className="text-blue-500">EXPERIENCE</span></span> <p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600">
</Link> Powered by OpenCCB &copy; 2023. Advanced Agentic Coding.
</p>
<nav className="hidden md:flex items-center gap-8"> </footer>
<Link href="/" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">Catalog</Link> </AuthProvider>
<Link href="#" className="text-xs font-black uppercase tracking-widest text-gray-400 hover:text-white transition-colors">My Learning</Link> </BrandingProvider>
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
</nav>
</header>
<main className="flex-1">
{children}
</main>
{/* Footer */}
<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">
Powered by OpenCCB &copy; 2023. Advanced Agentic Coding.
</p>
</footer>
</AuthProvider>
</body> </body>
</html> </html>
); );
+48 -3
View File
@@ -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>
);
};
+20
View File
@@ -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();
} }
}; };
+13 -1
View File
@@ -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"
+219 -8
View File
@@ -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">
<Building2 className="w-6 h-6" /> {org.logo_url ? (
<img src={org.logo_url} alt={org.name} className="w-full h-full object-contain" />
) : (
<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">
Details <ExternalLink className="w-3 h-3" /> <button
</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" />
</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>
<h2 className="text-4xl font-black tracking-tight">{lesson.title}</h2> <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>
<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>
)} )}
+298 -60
View File
@@ -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">
</div> <button
<div className="p-4 space-y-2"> onClick={() => handleReorderModule(mIndex, 'up')}
{module.lessons.map(lesson => ( disabled={mIndex === 0}
<Link href={`/courses/${params.id}/lessons/${lesson.id}`} key={lesson.id}> className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
<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-3"> <ChevronUp className="w-4 h-4" />
<span className="text-blue-400 text-lg group-hover/lesson:scale-110 transition-transform"> </button>
{lesson.content_type === 'video' ? '🎬' : '📄'} <button
</span> onClick={() => handleReorderModule(mIndex, 'down')}
<span>{lesson.title}</span> disabled={mIndex === modules.length - 1}
</div> className="text-gray-500 hover:text-blue-400 disabled:opacity-0 transition-colors"
<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>} <ChevronDown className="w-4 h-4" />
<span className="text-xs text-gray-500 capitalize">{lesson.content_type}</span> </button>
</div> </div>
<GripVertical className="text-gray-600 w-5 h-5 cursor-grab active:cursor-grabbing" />
{editingId === module.id ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle(module.id, 'module')}
className="bg-black/40 border border-blue-500/50 rounded px-3 py-1 flex-1 text-white focus:outline-none"
/>
<button onClick={() => handleSaveTitle(module.id, 'module')} className="text-green-400 hover:text-green-300">
<Save className="w-5 h-5" />
</button>
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-red-400">
<X className="w-5 h-5" />
</button>
</div> </div>
</Link> ) : (
<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>
<button
onClick={() => { setEditingId(module.id); setEditValue(module.title); }}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white transition-opacity"
>
<Pencil className="w-4 h-4" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-3">
<button
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>
</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 || ""}
@@ -98,8 +98,8 @@ export default function FillInTheBlanksBlock({ id, title, content, editMode, onC
}} }}
disabled={submitted} disabled={submitted}
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10") ? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5" : "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
}`} }`}
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }} style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
placeholder="..." placeholder="..."
@@ -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 || ""}
@@ -112,8 +112,8 @@ export default function MatchingBlock({ id, title, pairs, editMode, onChange }:
key={i} key={i}
onClick={() => !submitted && setSelectedLeft(i)} onClick={() => !submitted && setSelectedLeft(i)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" : className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" : matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20" "border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
}`} }`}
> >
{pair.left} {pair.left}
@@ -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 || ""}
@@ -128,7 +128,7 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
disabled={isPicked || submitted} disabled={isPicked || submitted}
onClick={() => handlePick(item.originalIdx)} onClick={() => handlePick(item.originalIdx)}
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" : className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5" "border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
}`} }`}
> >
{item.value} {item.value}
@@ -151,8 +151,8 @@ export default function OrderingBlock({ id, title, items, editMode, onChange }:
key={i} key={i}
onClick={() => !submitted && handlePick(idx)} onClick={() => !submitted && handlePick(idx)}
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" : className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" : isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10" "border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
}`} }`}
> >
<span className="opacity-50 text-xs">{i + 1}.</span> <span className="opacity-50 text-xs">{i + 1}.</span>
@@ -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 || ""}
@@ -77,8 +77,8 @@ export default function ShortAnswerBlock({ id, title, prompt, correctAnswers, ed
onChange={(e) => setUserAnswer(e.target.value)} onChange={(e) => setUserAnswer(e.target.value)}
disabled={submitted} disabled={submitted}
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100") ? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
: "border-white/10 focus:border-blue-500 text-white" : "border-white/10 focus:border-blue-500 text-white"
}`} }`}
placeholder="Type your answer..." placeholder="Type your answer..."
/> />
+47
View File
@@ -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();
});
},
}; };