feat: Introduce course marketing features with dedicated metadata, image generation, and UI in both studio and experience apps.

This commit is contained in:
2026-03-04 15:41:34 -03:00
parent 4458decd22
commit 01c54429a0
25 changed files with 1453 additions and 401 deletions
+5 -3
View File
@@ -27,7 +27,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
- **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.).
- **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada.
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
- **AI Video Generation**: Generación automática de contenido de video a partir de guiones de lecciones o prompts personalizados, optimizada para ejecución local o remota (servidor t-800) mediante *Stable Video Diffusion*.
- **AI Image Generation**: Generación automática de contenido visual a partir de guiones de lecciones o prompts personalizados, con soporte para resoluciones personalizadas (HD/4K) y gestión de portadas de curso de alta calidad.
- **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos.
- **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual.
- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes.
- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso.
@@ -78,7 +79,7 @@ OpenCCB es altamente escalable. A continuación se detallan los requisitos recom
- **IA Local**:
- **Faster-Whisper**: Transcripción de audio a texto.
- **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios.
- **Stable Video Diffusion**: Motor de generación de video optimizado para CPU.
- **Stable Diffusion**: Motor de generación de imágenes optimizado para CPU.
- **i18n Infrastructure**: Sistema de traducción reactivo para soporte global.
- **Document Management**: Motor de previsualización de documentos PDF nativo.
@@ -644,7 +645,8 @@ Obtiene una lista de todas las organizaciones registradas.
- **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes.
- **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción.
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
- **Predictive Risk Dashboard**: Panel de control para instructores que visualiza el riesgo de deserción escolar mediante semáforos de color y motivos detallados del riesgo.
- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad, integración de imágenes generadas por IA y desgloses de objetivos de aprendizaje.
- **AI Image Resolution Selector**: Control granular sobre las dimensiones de salida para activos visuales generados por IA (720p, 1080p, 4K).
## Próximos Pasos (Roadmap 2024-2025)
+77 -43
View File
@@ -5,16 +5,26 @@
# 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
# 2. Hardware detection (NVIDIA GPU vs CPU)
# 3. Environment configuration (.env)
# 4. Database creation and migrations
# 5. System initialization (Admin account)
# 4. Database creation and migrations (CMS, LMS, AI Bridge)
# 5. System initialization (Admin account and Organization)
# Version: 1.5 - AI Marketing & High-Res Support
set -e
echo "===================================================="
echo " 🚀 Bienvenido al Instalador de OpenCCB"
echo " 🚀 Bienvenido al Instalador de OpenCCB v1.5"
echo " (Edición Marketing & Imágenes de Alta Resolución)"
echo "===================================================="
echo ""
# Parse arguments
FAST_MODE="false"
for arg in "$@"; do
if [ "$arg" == "--fast" ]; then
FAST_MODE="true"
fi
done
# 1. Detección y Clonación
if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then
echo "✅ Proyecto detectado en el directorio actual."
@@ -31,46 +41,48 @@ else
fi
fi
# 2. Prerequisite Installation
install_pkg() {
if ! command -v "$1" &> /dev/null; then
echo "🔧 Instalando $1..."
apt-get update && apt-get install -y "$1"
else
echo "$1 ya está instalado."
fi
}
if [ "$FAST_MODE" == "false" ]; then
# 2. Prerequisite Installation
install_pkg() {
if ! command -v "$1" &> /dev/null; then
echo "🔧 Instalando $1..."
apt-get update && apt-get install -y "$1"
else
echo "$1 ya está instalado."
fi
}
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"
if ! docker compose version &> /dev/null; then
install_pkg "docker-compose-v2"
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"
if ! docker compose version &> /dev/null; then
install_pkg "docker-compose-v2"
fi
fi
fi
fi
if ! command -v cargo &> /dev/null; then
echo "🔧 Instalando Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source $HOME/.cargo/env
fi
if ! command -v cargo &> /dev/null; then
echo "🔧 Instalando Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source $HOME/.cargo/env
fi
if ! command -v node &> /dev/null; then
echo "🔧 Instalando Node.js vía 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
fi
if ! command -v node &> /dev/null; then
echo "🔧 Instalando Node.js vía 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
fi
if ! command -v sqlx &> /dev/null; then
echo "🔧 Instalando sqlx-cli..."
cargo install sqlx-cli --no-default-features --features postgres
if ! command -v sqlx &> /dev/null; then
echo "🔧 Instalando sqlx-cli..."
cargo install sqlx-cli --no-default-features --features postgres
fi
fi
# 4. Environment Configuration
@@ -117,14 +129,14 @@ read -p "Ingrese la URL de Ollama Remoto [$DEFAULT_OLLAMA]: " REMOTE_OLLAMA_URL
REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA}
read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL
REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER}
read -p "Ingrese la URL del Video Bridge Remoto [http://t-800:8080]: " REMOTE_VIDEO_URL
REMOTE_VIDEO_URL=${REMOTE_VIDEO_URL:-"http://t-800:8080"}
read -p "Ingrese la URL del Image Bridge Remoto [http://t-800:8080]: " REMOTE_IMAGE_URL
REMOTE_IMAGE_URL=${REMOTE_IMAGE_URL:-"http://t-800:8080"}
read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL
LLM_MODEL=${LLM_MODEL:-llama3.2:3b}
update_env "AI_PROVIDER" "local"
update_env "LOCAL_LLM_MODEL" "$LLM_MODEL"
update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_VIDEO_URL"
update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_IMAGE_URL"
if [ "$ENV_CHOICE" == "dev" ]; then
update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL"
@@ -140,6 +152,16 @@ else
update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL"
fi
# 6.5 Configuración de Base de Datos Externa (para bridges remotos)
echo ""
echo "🔌 Configuración de Base de Datos para Bridge Remoto"
echo "Si el equipo t-800 necesita reportar progreso, debe conocer la IP de este servidor."
SERVER_IP=$(ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -v '127.0.0.1' | head -n 1)
DEFAULT_BRIDGE_DB="postgresql://user:${DB_PASS:-password}@${SERVER_IP}:5432/openccb_cms?sslmode=disable"
read -p "Ingrese la URL de la DB que verá el Bridge Remoto [$DEFAULT_BRIDGE_DB]: " BRIDGE_DB_URL
BRIDGE_DB_URL=${BRIDGE_DB_URL:-$DEFAULT_BRIDGE_DB}
update_env "BRIDGE_DATABASE_URL" "$BRIDGE_DB_URL"
# AI setup is now purely remote. Skipping local container configuration.
# Solicitar credenciales de DB si no están configuradas
@@ -219,9 +241,21 @@ if [ "$ADMIN_EXISTS" != "t" ]; then
ORG_NAME="Default Organization"
fi
echo ""
echo "🚀 Iniciando todos los servicios..."
docker compose up -d --build
# Selective Build/Rebuild
if [ "$FAST_MODE" == "true" ]; then
echo "⚡ Modo FAST activado. Saltando comprobaciones y reconstrucción de imágenes."
docker compose up -d
else
echo ""
read -p "¿Desea RECONSTRUIR las imágenes de Docker? (Recomendado si hay cambios de código) [y/N]: " REBUILD_CHOICE
if [[ "$REBUILD_CHOICE" =~ ^[Yy]$ ]]; then
echo "🚀 Reconstruyendo e iniciando servicios..."
docker compose up -d --build
else
echo "🚀 Iniciando servicios (sin reconstruir)..."
docker compose up -d
fi
fi
if [ "$ADMIN_EXISTS" != "t" ]; then
echo "⏳ Esperando a que el API CMS esté listo..."
+8 -2
View File
@@ -218,11 +218,17 @@
- [x] Perfiles profesionales públicos con control de privacidad.
- [x] Visualización de progreso y nivel de XP.
## Fase 19: Presentación Visual y Marketing de Cursos ✅
- [x] **Generación de Imágenes de Alta Resolución**: Soporte para dimensiones personalizadas (HD/4K) para lecciones y cursos via AI Bridge. (Completado)
- [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado)
- [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado)
- [x] **Gestión de Portadas de Curso**: Integración de imágenes generadas por IA como activos principales del curso con previsualización dinámica. (Completado)
---
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3 con soporte para Deep Linking**, **Analíticas Predictivas de Riesgo de Abandono**, **Integración de Videoconferencia (Jitsi)** y **Portafolios con Perfiles Públicos**.
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos**, **Generación de Imágenes HD/4K** y **Landing Pages de Cursos (Marketing) automatizadas**.
**Próximas Prioridades**:
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos.
3. **IA Generativa Avanzada**: Generación automática de contenido de video a partir de guiones.
3. **IA Generativa Avanzada**: Generación automática de contenido interactivo adicional.
@@ -0,0 +1,60 @@
-- Migration: Add Course Marketing Metadata and Image Generation fields
-- 1. Add columns to courses table
ALTER TABLE courses
ADD COLUMN IF NOT EXISTS marketing_metadata JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS course_image_url TEXT,
ADD COLUMN IF NOT EXISTS generation_status VARCHAR(20) DEFAULT 'idle',
ADD COLUMN IF NOT EXISTS generation_progress INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS generation_error TEXT;
-- 2. Update fn_create_course to handle initial marketing_metadata
CREATE OR REPLACE FUNCTION fn_create_course(
p_organization_id UUID,
p_instructor_id UUID,
p_title VARCHAR(255),
p_pacing_mode VARCHAR(50) DEFAULT 'self_paced'
) RETURNS SETOF courses AS $$
BEGIN
RETURN QUERY
INSERT INTO courses (organization_id, instructor_id, title, pacing_mode, marketing_metadata)
VALUES (p_organization_id, p_instructor_id, p_title, p_pacing_mode, '{}')
RETURNING *;
END;
$$ LANGUAGE plpgsql;
-- 3. Update fn_update_course to include marketing fields
CREATE OR REPLACE FUNCTION fn_update_course(
p_id UUID,
p_organization_id UUID,
p_title VARCHAR(255),
p_description TEXT,
p_passing_percentage INTEGER,
p_pacing_mode VARCHAR(50),
p_start_date TIMESTAMPTZ,
p_end_date TIMESTAMPTZ,
p_certificate_template VARCHAR(255) DEFAULT NULL,
p_price DOUBLE PRECISION DEFAULT 0.0,
p_currency VARCHAR(10) DEFAULT 'USD',
p_marketing_metadata JSONB DEFAULT NULL,
p_course_image_url TEXT DEFAULT NULL
) RETURNS SETOF courses AS $$
BEGIN
RETURN QUERY
UPDATE courses
SET title = COALESCE(p_title, title),
description = COALESCE(p_description, description),
passing_percentage = COALESCE(p_passing_percentage, passing_percentage),
pacing_mode = COALESCE(p_pacing_mode, pacing_mode),
start_date = p_start_date,
end_date = p_end_date,
certificate_template = COALESCE(p_certificate_template, certificate_template),
price = COALESCE(p_price, price),
currency = COALESCE(p_currency, currency),
marketing_metadata = COALESCE(p_marketing_metadata, marketing_metadata),
course_image_url = COALESCE(p_course_image_url, course_image_url),
updated_at = NOW()
WHERE id = p_id AND organization_id = p_organization_id
RETURNING *;
END;
$$ LANGUAGE plpgsql;
Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

+68 -13
View File
@@ -3,7 +3,7 @@ import time
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import torch
from diffusers import StableDiffusionPipeline
from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
from PIL import Image
import uuid
@@ -20,14 +20,20 @@ pipe = None
def load_model():
global pipe
print("Loading Stable Diffusion model on CPU...")
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Loading Stable Diffusion model on {device}...")
pipe = StableDiffusionPipeline.from_pretrained(
MODEL_ID,
torch_dtype=torch.float32,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
)
pipe.to("cpu")
# pipe.enable_model_cpu_offload()
pipe.enable_attention_slicing()
# Use a high-quality scheduler for better detail
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
pipe.to(device)
if device == "cpu":
pipe.enable_attention_slicing()
print("Model loaded successfully.")
from contextlib import asynccontextmanager
@@ -43,9 +49,16 @@ app = FastAPI(lifespan=lifespan)
from fastapi.staticfiles import StaticFiles
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
import psycopg2
class ImageRequest(BaseModel):
prompt: str
lesson_id: str
database_url: str = None
table_name: str = "lessons"
progress_column: str = "generation_progress"
width: int = 512
height: int = 512
@app.post("/generate")
async def generate_image(request: ImageRequest):
@@ -53,24 +66,66 @@ async def generate_image(request: ImageRequest):
if pipe is None:
load_model()
num_steps = 150
def progress_callback(step: int, timestep: int, latents: torch.FloatTensor):
if request.database_url and request.lesson_id:
try:
progress = int((step / num_steps) * 100)
conn = psycopg2.connect(request.database_url)
cur = conn.cursor()
# Use psycopg2.sql for safe table/column names if possible,
# but here we'll just format since we control the backend values
query = f"UPDATE {request.table_name} SET {request.progress_column} = %s WHERE id = %s"
cur.execute(query, (progress, request.lesson_id))
conn.commit()
cur.close()
conn.close()
except Exception as db_e:
print(f"Database update error: {db_e}")
def callback_dynamic_cfg(pipe, step_index, timestep, callback_kwargs):
if progress_callback:
progress_callback(step_index, timestep, None)
return callback_kwargs
try:
print(f"Generating image for prompt: {request.prompt}")
quality_prompt = f"{request.prompt}, highly detailed, high quality, masterpiece, 8k, realistic, photographic, sharp focus, perfect anatomy"
negative_prompt = "deformed, distorted, disfigured, poorly drawn, bad anatomy, wrong anatomy, extra limb, missing limb, floating limbs, disconnected limbs, mutation, mutated, ugly, disgusting, blurry, low quality, low resolution, bad hands, extra fingers, cartoon, anime, illustration, draft, grainy"
print(f"Generating image ({request.width}x{request.height}) for prompt: {quality_prompt}")
generator = torch.manual_seed(42)
# Using a small number of steps for speed since it's on CPU
# Generation with custom resolution
image = pipe(
request.prompt,
num_inference_steps=20,
generator=generator
quality_prompt,
negative_prompt=negative_prompt,
num_inference_steps=num_steps,
guidance_scale=8.5,
width=request.width,
height=request.height,
callback_on_step_end=callback_dynamic_cfg
).images[0]
# Ensure progress is 100% at the end
if request.database_url and request.lesson_id:
try:
conn = psycopg2.connect(request.database_url)
cur = conn.cursor()
query = f"UPDATE {request.table_name} SET {request.progress_column} = 100 WHERE id = %s"
cur.execute(query, (request.lesson_id,))
conn.commit()
cur.close()
conn.close()
except:
pass
image_filename = f"image_{request.lesson_id}_{uuid.uuid4().hex[:8]}.png"
image_path = os.path.join(OUTPUT_DIR, image_filename)
image.save(image_path)
# Return the absolute URL pointing to t-800 so the frontend can find it
hostname = os.getenv("BRIDGE_HOSTNAME", "localhost")
hostname = os.getenv("BRIDGE_HOSTNAME", "t-800")
full_url = f"http://{hostname}:8080/outputs/{image_filename}"
return {"status": "completed", "url": full_url}
+218 -23
View File
@@ -408,6 +408,17 @@ pub async fn update_course(
.map(|s| s.to_string())
.unwrap_or(existing.currency);
let marketing_metadata = payload
.get("marketing_metadata")
.cloned()
.or(existing.marketing_metadata);
let course_image_url = payload
.get("course_image_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or(existing.course_image_url);
// BEGIN TRANSACTION
let mut tx = pool
.begin()
@@ -425,7 +436,7 @@ pub async fn update_course(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let course = sqlx::query_as::<_, Course>(
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
)
.bind(id)
.bind(org_ctx.id)
@@ -438,6 +449,8 @@ pub async fn update_course(
.bind(certificate_template)
.bind(price)
.bind(currency)
.bind(marketing_metadata)
.bind(course_image_url)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
@@ -806,13 +819,19 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
let filename = url.trim_start_matches("/assets/");
let file_path = format!("uploads/{}", filename);
// 2. Set status to processing
// 2. Set status to processing ONLY if it's still queued (not cancelled/idle)
tracing::info!("Starting transcription for lesson {} (file: {})", lesson_id, file_path);
sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1")
let rows_affected = sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1 AND transcription_status = 'queued'")
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Update to processing failed: {}", e))?;
.map_err(|e| format!("Update to processing failed: {}", e))?
.rows_affected();
if rows_affected == 0 {
tracing::info!("Transcription task {} was cancelled or is already processing. Aborting.", lesson_id);
return Ok(());
}
// 3. Read file
let file_data = tokio::fs::read(&file_path)
@@ -878,8 +897,8 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
}
}
// 6. Update lesson with bilinguial transcription
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2")
// 6. Update lesson with bilinguial transcription - ONLY if not cancelled (idle)
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2 AND transcription_status = 'processing'")
.bind(&transcription_result)
.bind(lesson_id)
.execute(&pool)
@@ -1286,6 +1305,8 @@ pub async fn generate_quiz(
#[derive(Deserialize)]
pub struct VideoAIRequest {
pub prompt: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
}
pub async fn generate_image(
@@ -1334,8 +1355,11 @@ pub async fn generate_image(
// 3. Spawn background task
let pool_clone = pool.clone();
let prompt_to_task = payload.prompt.clone();
let user_id = claims.sub;
let width = payload.width;
let height = payload.height;
tokio::spawn(async move {
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task).await {
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), false, width, height).await {
tracing::error!("Image generation task failed for lesson {}: {}", id, e);
}
});
@@ -1343,36 +1367,123 @@ pub async fn generate_image(
Ok(Json(updated_lesson))
}
pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_prompt: Option<String>) -> Result<(), String> {
// 1. Set status to processing
sqlx::query("UPDATE lessons SET video_generation_status = 'processing' WHERE id = $1")
.bind(lesson_id)
pub async fn generate_course_image(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<VideoAIRequest>,
) -> Result<Json<Course>, StatusCode> {
// 1. Fetch course
let _course =
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Set status to queued
let updated_course = sqlx::query_as::<_, Course>(
"UPDATE courses SET generation_status = 'queued', generation_error = NULL WHERE id = $1 RETURNING *",
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database update failed (course image queued): {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
log_action(
&pool,
org_ctx.id,
claims.sub,
"COURSE_IMAGE_GENERATION_QUEUED",
"Course",
id,
json!({ "status": "queued" }),
)
.await;
// 3. Spawn background task
let pool_clone = pool.clone();
let prompt_to_task = payload.prompt.clone();
let user_id = claims.sub;
let width = payload.width;
let height = payload.height;
tokio::spawn(async move {
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), true, width, height).await {
tracing::error!("Image generation task failed for course {}: {}", id, e);
}
});
Ok(Json(updated_course))
}
pub async fn run_image_generation_task(
pool: PgPool,
id: Uuid,
custom_prompt: Option<String>,
user_id: Option<Uuid>,
is_course: bool,
width: Option<u32>,
height: Option<u32>
) -> Result<(), String> {
let (status_col, progress_col, error_col, table_name) = if is_course {
("generation_status", "generation_progress", "generation_error", "courses")
} else {
("video_generation_status", "generation_progress", "video_generation_error", "lessons")
};
// 1. Set status to processing ONLY if it's still queued (not cancelled/idle)
let query = format!(
"UPDATE {} SET {} = 'processing' WHERE id = $1 AND {} = 'queued'",
table_name, status_col, status_col
);
let rows_affected = sqlx::query(&query)
.bind(id)
.execute(&pool)
.await
.map_err(|e| format!("Update to processing failed: {}", e))?;
.map_err(|e| format!("Update to processing failed: {}", e))?
.rows_affected();
if rows_affected == 0 {
tracing::info!("Task {} was cancelled or is already processing. Aborting background thread.", id);
return Ok(());
}
// 2. Call Local Video Bridge (Python)
let client = reqwest::Client::new();
let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
.unwrap_or_else(|_| "http://t-800:8080".to_string());
let bridge_url = format!("{}/generate", bridge_base_url);
// Fallback logic for prompt: Custom Prompt > Title
let final_prompt = match custom_prompt {
Some(p) if !p.is_empty() => p,
_ => {
sqlx::query_scalar("SELECT title FROM lessons WHERE id = $1")
.bind(lesson_id)
let title: String = sqlx::query_scalar(&format!("SELECT title FROM {} WHERE id = $1", table_name))
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?
.map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?;
title
}
};
let database_url = std::env::var("BRIDGE_DATABASE_URL")
.unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap_or_default());
let response = client.post(bridge_url)
.json(&serde_json::json!({
"prompt": final_prompt,
"lesson_id": lesson_id.to_string()
"lesson_id": id.to_string(), // The bridge uses lesson_id as a generic id for progress reporting
"database_url": database_url,
"table_name": table_name, // Pass table name so bridge knows where to update progress
"progress_column": progress_col,
"width": width,
"height": height
}))
.send()
.await
@@ -1380,24 +1491,108 @@ pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_pro
if !response.status().is_success() {
let err_text = response.text().await.unwrap_or_default();
// Update error in DB
let _ = sqlx::query(&format!("UPDATE {} SET {} = $1, {} = 'error' WHERE id = $2", table_name, error_col, status_col))
.bind(&err_text)
.bind(id)
.execute(&pool)
.await;
return Err(format!("Video bridge error: {}", err_text));
}
let result: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse video bridge response: {}", e))?;
let content_url = result["url"].as_str()
let bridge_content_url = result["url"].as_str()
.ok_or_else(|| "Video bridge response missing URL".to_string())?;
// 3. Complete task
// --- Download image and store as Asset ---
// 1. Download from bridge
let image_bytes = client.get(bridge_content_url)
.send()
.await
.map_err(|e| format!("Failed to download generated image: {}", e))?
.bytes()
.await
.map_err(|e| format!("Failed to read image bytes: {}", e))?;
// 2. Fetch context
let (org_id, course_id, instructor_id): (Uuid, Uuid, Uuid) = if is_course {
let c = sqlx::query_as::<sqlx::Postgres, (Uuid, Uuid, Uuid)>(
"SELECT organization_id, id as course_id, instructor_id FROM courses WHERE id = $1"
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| format!("Failed to fetch course context: {}", e))?;
c
} else {
sqlx::query_as::<sqlx::Postgres, (Uuid, Uuid, Uuid)>(
"SELECT l.organization_id, m.course_id, c.instructor_id
FROM lessons l
JOIN modules m ON l.module_id = m.id
JOIN courses c ON m.course_id = c.id
WHERE l.id = $1"
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| format!("Failed to fetch lesson context: {}", e))?
};
let final_user_id = user_id.unwrap_or(instructor_id);
// 3. Save file locally
let asset_id = Uuid::new_v4();
let storage_filename = format!("{}.png", asset_id);
let storage_path = format!("uploads/{}", storage_filename);
let _ = tokio::fs::create_dir_all("uploads").await;
tokio::fs::write(&storage_path, &image_bytes).await.map_err(|e| e.to_string())?;
let size_bytes = image_bytes.len() as i64;
let local_url = format!("/assets/{}", storage_filename);
// 4. Register in assets table
sqlx::query(
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2"
r#"
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(content_url)
.bind(lesson_id)
.bind(asset_id)
.bind(org_id)
.bind(final_user_id)
.bind(course_id)
.bind(format!("AI: {}", final_prompt))
.bind(&storage_path)
.bind("image/png")
.bind(size_bytes)
.execute(&pool)
.await
.map_err(|e| format!("Update to completed failed: {}", e))?;
.map_err(|e| format!("Failed to register asset: {}", e))?;
// 3. Complete task updating entity with local URL - ONLY if not cancelled (idle)
if is_course {
sqlx::query(
"UPDATE courses SET generation_status = 'completed', course_image_url = $1 WHERE id = $2 AND generation_status = 'processing'"
)
.bind(local_url)
.bind(id)
.execute(&pool)
.await
.map_err(|e| format!("Failed to complete course task: {}", e))?;
} else {
sqlx::query(
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2 AND video_generation_status = 'processing'"
)
.bind(local_url)
.bind(id)
.execute(&pool)
.await
.map_err(|e| format!("Failed to complete lesson task: {}", e))?;
}
Ok(())
}
+14 -10
View File
@@ -15,6 +15,7 @@ pub struct BackgroundTask {
pub course_title: Option<String>,
pub transcription_status: Option<String>,
pub video_generation_status: Option<String>,
pub generation_progress: Option<i32>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -36,6 +37,7 @@ pub async fn get_background_tasks(
c.title as course_title,
l.transcription_status,
l.video_generation_status,
l.generation_progress,
l.updated_at
FROM lessons l
JOIN modules m ON l.module_id = m.id
@@ -105,16 +107,18 @@ pub async fn cancel_task(
// We can't easily kill a running tokio task unless we had a handle map, which we don't.
// So this is effectively "Dismiss".
sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to cancel task: {}", e),
)
})?;
sqlx::query(
"UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1"
)
.bind(id)
.execute(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to cancel task: {}", e),
)
})?;
Ok(StatusCode::NO_CONTENT)
}
+2 -1
View File
@@ -92,7 +92,7 @@ async fn main() {
for lesson_id in queued_video_lessons {
tracing::info!("Processing video generation for lesson: {}", lesson_id);
if let Err(e) =
handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None).await
handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None, None, false, None, None).await
{
tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e);
let _ = sqlx::query(
@@ -176,6 +176,7 @@ async fn main() {
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
.route("/lessons/{id}/generate-image", post(handlers::generate_image))
.route("/courses/{id}/generate-image", post(handlers::generate_course_image))
.route("/courses/generate", post(handlers::generate_course))
.route("/courses/{id}/export", get(handlers::export_course))
.route("/courses/import", post(handlers::import_course))
+5
View File
@@ -17,6 +17,11 @@ pub struct Course {
pub certificate_template: Option<String>,
pub price: f64,
pub currency: String,
pub marketing_metadata: Option<serde_json::Value>,
pub course_image_url: Option<String>,
pub generation_status: Option<String>,
pub generation_progress: Option<i32>,
pub generation_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+1 -1
View File
@@ -12,7 +12,7 @@ RUN cargo build --release -p lms-service
FROM node:18-alpine AS node-builder
WORKDIR /app
COPY web/experience/package*.json ./
RUN npm install
RUN npm ci
COPY web/experience/ .
ARG NEXT_PUBLIC_LMS_API_URL
ENV NEXT_PUBLIC_LMS_API_URL=$NEXT_PUBLIC_LMS_API_URL
@@ -459,6 +459,20 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
);
})}
</div>
) : (lesson.content_url) ? (
<div className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
<MediaPlayer
id="main-media-fallback"
lessonId={params.lessonId}
title={lesson.title}
url={lesson.content_url}
media_type={(lesson.content_type as any) || 'video'}
onTimeUpdate={setCurrentTime}
initialPlayCount={0}
isGraded={lesson.is_graded}
hasTranscription={!!lesson.transcription}
/>
</div>
) : (
<div className="py-20 text-center glass-card border-dashed border-black/10 dark:border-white/10">
<p className="text-gray-600 dark:text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
+326 -290
View File
@@ -8,6 +8,7 @@ import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from
import { useAuth } from "@/context/AuthContext";
import DiscussionBoard from "@/components/DiscussionBoard";
import { AnnouncementsList } from "@/components/AnnouncementsList";
import AboutCourse from "@/components/AboutCourse";
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const { user } = useAuth();
@@ -144,310 +145,345 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />;
};
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
return (
<div className="max-w-4xl mx-auto px-6 py-20">
<div className="mb-16">
<div className="flex items-center gap-2 mb-6 text-blue-600 dark:text-blue-500 font-bold text-xs uppercase tracking-widest">
<Link href="/" className="hover:text-gray-900 dark:hover:text-white transition-colors">Catálogo</Link>
<ChevronRight size={14} className="text-gray-600" />
<span>Detalles del Curso</span>
<div className="max-w-4xl mx-auto px-6 py-20 pb-40">
<div className="mb-12">
<div className="flex items-center gap-2 mb-10 text-blue-600 dark:text-blue-500 font-bold text-[10px] uppercase tracking-[0.2em] bg-blue-500/5 dark:bg-blue-500/10 w-fit px-4 py-2 rounded-full border border-blue-500/20">
<Link href="/" className="hover:text-gray-900 dark:hover:text-white transition-colors">Catálogo CCB</Link>
<ChevronRight size={14} className="text-blue-300 dark:text-blue-800" />
<span className="text-slate-900 dark:text-white">Exploración de Curso</span>
</div>
<h1 className="text-5xl font-black tracking-tighter mb-6 text-gray-900 dark:text-white">{courseData.title}</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed max-w-2xl mb-10">
{courseData.description || "Domina los principios básicos y las técnicas avanzadas en este plan de estudios estructurado. Cada módulo está diseñado para proporcionar conocimientos prácticos y experiencia práctica."}
</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' ? 'Dirigido por un Instructor' : 'A tu Ritmo'}
</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-600 dark:text-gray-500 uppercase tracking-widest">
<Calendar size={14} />
<span>
{courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'Por Determinar'}
<span className="mx-2 text-gray-300 dark:text-gray-700"></span>
{courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'Por Determinar'}
</span>
{/* --- PREMIUM TAB SYSTEM --- */}
<div className="flex items-center gap-1 bg-slate-100 dark:bg-white/5 p-1.5 rounded-[1.5rem] w-fit mb-12 shadow-inner border border-slate-200 dark:border-white/5">
<button
onClick={() => setActiveTab('outline')}
className={`px-8 py-3 rounded-[1.25rem] text-[10px] font-black uppercase tracking-[0.2em] transition-all ${activeTab === 'outline'
? 'bg-white dark:bg-blue-600 text-blue-600 dark:text-white shadow-xl shadow-blue-500/20'
: 'text-slate-400 dark:text-gray-500 hover:text-slate-600 dark:hover:text-gray-300'
}`}
>
<div className="flex items-center gap-3">
<BookOpen size={16} /> Contenido
</div>
)}
</button>
<button
onClick={() => setActiveTab('about')}
className={`px-8 py-3 rounded-[1.25rem] text-[10px] font-black uppercase tracking-[0.2em] transition-all ${activeTab === 'about'
? 'bg-white dark:bg-indigo-600 text-indigo-600 dark:text-white shadow-xl shadow-indigo-500/20'
: 'text-slate-400 dark:text-gray-500 hover:text-slate-600 dark:hover:text-gray-300'
}`}
>
<div className="flex items-center gap-3">
<Sparkles size={16} /> Resumen del Curso
</div>
</button>
</div>
{instructors.length > 0 && (
<div className="mb-10 animate-in fade-in slide-in-from-left-4 duration-700">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 mb-4 block">Equipo docente</span>
<div className="flex flex-wrap gap-6">
{instructors.map((inst) => (
<div key={inst.id} className="flex items-center gap-3 glass border-black/5 dark:border-white/5 px-4 py-2 rounded-2xl hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<div className="w-8 h-8 rounded-full bg-blue-500/10 dark:bg-blue-500/20 flex items-center justify-center border border-blue-500/20 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 font-bold text-xs">
{inst.full_name?.charAt(0) || inst.email?.charAt(0)}
{activeTab === 'about' ? (
<AboutCourse course={courseData} instructors={instructors} />
) : (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
<h1 className="text-5xl lg:text-7xl font-black tracking-tighter mb-8 text-gray-900 dark:text-white">{courseData.title}</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed max-w-2xl mb-10">
{courseData.description || "Domina los principios básicos y las técnicas avanzadas en este plan de estudios estructurado. Cada módulo está diseñado para proporcionar conocimientos prácticos y experiencia práctica."}
</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' ? 'Dirigido por un Instructor' : 'A tu Ritmo'}
</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-600 dark:text-gray-500 uppercase tracking-widest">
<Calendar size={14} />
<span>
{courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'Por Determinar'}
<span className="mx-2 text-gray-300 dark:text-gray-700"></span>
{courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'Por Determinar'}
</span>
</div>
)}
</div>
{instructors.length > 0 && (
<div className="mb-10">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 mb-4 block">Equipo docente</span>
<div className="flex flex-wrap gap-6">
{instructors.map((inst) => (
<div key={inst.id} className="flex items-center gap-3 glass border-black/5 dark:border-white/5 px-4 py-2 rounded-2xl hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<div className="w-8 h-8 rounded-full bg-blue-500/10 dark:bg-blue-500/20 flex items-center justify-center border border-blue-500/20 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 font-bold text-xs">
{inst.full_name?.charAt(0) || inst.email?.charAt(0)}
</div>
<div>
<div className="text-xs font-bold text-gray-700 dark:text-gray-200">{inst.full_name}</div>
<div className="text-[8px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-500/60">{inst.role === 'primary' ? 'Instructor principal' : inst.role}</div>
</div>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between border-b border-slate-100 dark:border-white/5 pb-10 mb-10">
<div className="flex items-center gap-8">
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 dark:text-gray-600 mb-1">Módulos</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">{courseData.modules.length}</span>
</div>
<div className="w-px h-8 bg-black/10 dark:bg-white/10" />
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 dark:text-gray-600 mb-1">Lecciones Totales</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">
{courseData.modules.reduce((acc, m) => acc + m.lessons.length, 0)}
</span>
</div>
</div>
<div className="flex gap-2">
{!isEnrolled && (
<button
onClick={handleEnrollOrBuy}
className="btn-premium px-8 py-3 !bg-blue-600 !text-white shadow-lg shadow-blue-500/20 active:scale-95 flex items-center gap-2"
>
{courseData.price > 0 ? (
<>
<span className="font-black">{courseData.currency} {courseData.price.toFixed(0)}</span>
Comprar Ahora
</>
) : (
<>
<CheckCircle2 size={16} /> Inscribirse Gratis
</>
)}
</button>
)}
<Link href={`/courses/${params.id}/calendar`}>
<button className="px-6 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
<Calendar size={16} /> Cronología
</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">
📊 Progreso
</button>
</Link>
</div>
</div>
{/* AI Recommendations Section */}
{(loadingAI || recommendations.length > 0) && (
<div className="mb-20">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl glass border-purple-500/10 dark:border-purple-500/20 bg-purple-500/5 dark:bg-purple-500/10 flex items-center justify-center">
<Sparkles size={18} className="text-purple-600 dark:text-purple-400" />
</div>
<div>
<div className="text-xs font-bold text-gray-700 dark:text-gray-200">{inst.full_name}</div>
<div className="text-[8px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-500/60">{inst.role === 'primary' ? 'Instructor principal' : inst.role}</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">Tu Ruta de Aprendizaje IA</h2>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">Sugerencias personalizadas basadas en tu rendimiento</p>
</div>
</div>
<div className="grid gap-4">
{loadingAI ? (
<div className="glass-card border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/5 animate-pulse p-8">
<div className="h-4 w-1/3 bg-black/10 dark:bg-white/10 rounded mb-4"></div>
<div className="h-3 w-2/3 bg-black/10 dark:bg-white/10 rounded"></div>
</div>
) : (
recommendations.map((rec: Recommendation, i: number) => (
<div key={i} className="glass-card border-black/5 dark:border-white/5 hover:border-purple-600/30 dark:hover:border-purple-500/30 transition-all p-6 group">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest ${rec.priority === 'high' ? 'bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20' :
rec.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20' :
'bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20'
}`}>
Prioridad {rec.priority}
</div>
{rec.priority === 'high' && <AlertTriangle size={12} className="text-red-600 dark:text-red-400" />}
</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{rec.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed max-w-2xl">{rec.description}</p>
<div className="bg-black/5 dark:bg-white/5 rounded-lg p-3 inline-block">
<p className="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-1 italic">¿Por qué?</p>
<p className="text-xs text-gray-700 dark:text-gray-300 font-medium">{rec.reason}</p>
</div>
</div>
{rec.lesson_id && (
<Link href={`/courses/${params.id}/lessons/${rec.lesson_id}`}>
<button className="whitespace-nowrap px-6 py-3 rounded-xl bg-purple-600/10 dark:bg-purple-500/10 hover:bg-purple-600/20 dark:hover:bg-purple-500/20 border border-purple-600/20 dark:border-purple-500/30 text-purple-600 dark:text-purple-400 font-bold text-[10px] uppercase tracking-widest flex items-center gap-2 group-hover:gap-4 transition-all">
Ir a la Lección <ArrowRight size={14} />
</button>
</Link>
)}
</div>
</div>
))
)}
</div>
</div>
)}
{/* Live Sessions Section */}
{meetings.length > 0 && (
<div className="mb-20">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl glass border-blue-500/10 dark:border-blue-500/20 bg-blue-500/5 dark:bg-blue-500/10 flex items-center justify-center">
<Video size={18} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">Sesiones en Vivo</h2>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">Únete a las clases sincrónicas programadas</p>
</div>
</div>
<div className="grid gap-3">
{meetings.map((m) => (
<div key={m.id} className="glass-card border-black/5 dark:border-white/5 hover:border-blue-600/30 dark:hover:border-blue-500/30 transition-all p-5 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/5 dark:bg-blue-500/10 flex items-center justify-center text-blue-600 dark:text-blue-400">
<Calendar size={18} />
</div>
<div>
<h3 className="font-bold text-sm text-gray-900 dark:text-gray-100">{m.title}</h3>
<p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase mt-1">
{new Date(m.start_at).toLocaleString()} {m.duration_minutes} min
</p>
</div>
</div>
<a
href={m.join_url}
target="_blank"
rel="noreferrer"
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-bold text-[10px] uppercase tracking-widest flex items-center gap-2 transition-all"
>
Unirse <ExternalLink size={12} />
</a>
</div>
))}
</div>
</div>
)}
{/* Announcements Section */}
<div className="mb-16">
<AnnouncementsList courseId={params.id} isInstructor={user?.role === 'instructor' || user?.role === 'admin'} />
</div>
<div className="space-y-12">
{courseData.modules.map((module: Module, idx: number) => (
<div key={module.id} className="relative">
<div className="flex items-center gap-4 mb-6">
<div className="w-10 h-10 rounded-xl glass border-blue-500/10 dark:border-blue-500/20 bg-blue-500/5 dark:bg-blue-500/10 flex items-center justify-center">
<span className="text-blue-600 dark:text-blue-400 font-black text-xs">{idx + 1}</span>
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">{module.title}</h2>
</div>
<div className="grid gap-3 pl-14">
{module.lessons.map((lesson: any) => {
const locked = isLessonLocked(lesson.id);
const isPreviewable = lesson.is_previewable;
return (isEnrolled || isPreviewable) ? (
locked ? (
<div key={lesson.id} className="glass-card !p-4 border-black/5 dark:border-white/5 opacity-60 cursor-not-allowed">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center">
<Lock size={18} className="text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-500 dark:text-gray-400">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-600">Bloqueado por Prerrequisitos</span>
</div>
</div>
<div className="flex items-center gap-6">
<Lock size={18} className="text-gray-400 dark:text-gray-600" />
</div>
</div>
</div>
) : (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-black/5 dark:hover:bg-white/10 border-black/5 dark:border-white/5 active:scale-[0.99] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center group-hover:bg-blue-600/10 dark:group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className={`${isPreviewable && !isEnrolled ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'} group-hover:text-blue-600 dark:group-hover:text-blue-400`} />
) : (
<BookOpen size={18} className={`${isPreviewable && !isEnrolled ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'} group-hover:text-blue-600 dark:group-hover:text-blue-400`} />
)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-gray-700 dark:text-gray-200 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">{lesson.title}</h3>
{isPreviewable && !isEnrolled && (
<span className="text-[8px] font-black uppercase px-1.5 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20 rounded">Vista previa</span>
)}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</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>
</Link>
)
) : (
<div key={lesson.id} onClick={handleEnrollOrBuy} className="glass-card !p-4 group border-black/5 dark:border-white/5 opacity-60 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center">
<Clock size={18} className="text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-500 dark:text-gray-400">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-600 flex items-center gap-1">
Contenido Protegido
</span>
</div>
</div>
<div className="flex items-center gap-2 text-gray-400 dark:text-gray-600 font-bold text-[10px] uppercase tracking-widest">
<span>Bloqueado</span>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
{/* Discussions Section */}
<div className="mt-20">
<DiscussionBoard courseId={params.id} />
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 dark:text-gray-600 mb-1">Módulos</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">{courseData.modules.length}</span>
</div>
<div className="w-px h-8 bg-black/10 dark:bg-white/10" />
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 dark:text-gray-600 mb-1">Lecciones Totales</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">
{courseData.modules.reduce((acc, m) => acc + m.lessons.length, 0)}
</span>
</div>
</div>
<div className="flex gap-2">
{!isEnrolled && (
<button
onClick={handleEnrollOrBuy}
className="btn-premium px-8 py-3 !bg-blue-600 !text-white shadow-lg shadow-blue-500/20 active:scale-95 flex items-center gap-2"
>
{courseData.price > 0 ? (
<>
<span className="font-black">{courseData.currency} {courseData.price.toFixed(0)}</span>
Comprar Ahora
</>
) : (
<>
<CheckCircle2 size={16} /> Inscribirse Gratis
</>
)}
</button>
)}
<Link href={`/courses/${params.id}/calendar`}>
<button className="px-6 py-3 glass hover:border-blue-500/50 transition-all font-bold text-xs uppercase tracking-widest flex items-center gap-3 active:scale-95">
<Calendar size={16} /> Cronología
</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">
📊 Progreso
</button>
</Link>
</div>
</div>
</div>
{/* AI Recommendations Section */}
{(loadingAI || recommendations.length > 0) && (
<div className="mb-20">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl glass border-purple-500/10 dark:border-purple-500/20 bg-purple-500/5 dark:bg-purple-500/10 flex items-center justify-center">
<Sparkles size={18} className="text-purple-600 dark:text-purple-400" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">Tu Ruta de Aprendizaje IA</h2>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">Sugerencias personalizadas basadas en tu rendimiento</p>
</div>
</div>
<div className="grid gap-4">
{loadingAI ? (
<div className="glass-card border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/5 animate-pulse p-8">
<div className="h-4 w-1/3 bg-black/10 dark:bg-white/10 rounded mb-4"></div>
<div className="h-3 w-2/3 bg-black/10 dark:bg-white/10 rounded"></div>
</div>
) : (
recommendations.map((rec: Recommendation, i: number) => (
<div key={i} className="glass-card border-black/5 dark:border-white/5 hover:border-purple-600/30 dark:hover:border-purple-500/30 transition-all p-6 group">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest ${rec.priority === 'high' ? 'bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20' :
rec.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20' :
'bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20'
}`}>
Prioridad {rec.priority}
</div>
{rec.priority === 'high' && <AlertTriangle size={12} className="text-red-600 dark:text-red-400" />}
</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{rec.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed max-w-2xl">{rec.description}</p>
<div className="bg-black/5 dark:bg-white/5 rounded-lg p-3 inline-block">
<p className="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-1 italic">¿Por qué?</p>
<p className="text-xs text-gray-700 dark:text-gray-300 font-medium">{rec.reason}</p>
</div>
</div>
{rec.lesson_id && (
<Link href={`/courses/${params.id}/lessons/${rec.lesson_id}`}>
<button className="whitespace-nowrap px-6 py-3 rounded-xl bg-purple-600/10 dark:bg-purple-500/10 hover:bg-purple-600/20 dark:hover:bg-purple-500/20 border border-purple-600/20 dark:border-purple-500/30 text-purple-600 dark:text-purple-400 font-bold text-[10px] uppercase tracking-widest flex items-center gap-2 group-hover:gap-4 transition-all">
Ir a la Lección <ArrowRight size={14} />
</button>
</Link>
)}
</div>
</div>
))
)}
</div>
</div>
)}
{/* Live Sessions Section */}
{meetings.length > 0 && (
<div className="mb-20">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl glass border-blue-500/10 dark:border-blue-500/20 bg-blue-500/5 dark:bg-blue-500/10 flex items-center justify-center">
<Video size={18} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">Sesiones en Vivo</h2>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">Únete a las clases sincrónicas programadas</p>
</div>
</div>
<div className="grid gap-3">
{meetings.map((m) => (
<div key={m.id} className="glass-card border-black/5 dark:border-white/5 hover:border-blue-600/30 dark:hover:border-blue-500/30 transition-all p-5 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/5 dark:bg-blue-500/10 flex items-center justify-center text-blue-600 dark:text-blue-400">
<Calendar size={18} />
</div>
<div>
<h3 className="font-bold text-sm text-gray-900 dark:text-gray-100">{m.title}</h3>
<p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase mt-1">
{new Date(m.start_at).toLocaleString()} {m.duration_minutes} min
</p>
</div>
</div>
<a
href={m.join_url}
target="_blank"
rel="noreferrer"
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-bold text-[10px] uppercase tracking-widest flex items-center gap-2 transition-all"
>
Unirse <ExternalLink size={12} />
</a>
</div>
))}
</div>
</div>
)}
{/* Announcements Section */}
<div className="mb-16">
<AnnouncementsList courseId={params.id} isInstructor={user?.role === 'instructor' || user?.role === 'admin'} />
</div>
<div className="space-y-12">
{courseData.modules.map((module: Module, idx: number) => (
<div key={module.id} className="relative">
<div className="flex items-center gap-4 mb-6">
<div className="w-10 h-10 rounded-xl glass border-blue-500/10 dark:border-blue-500/20 bg-blue-500/5 dark:bg-blue-500/10 flex items-center justify-center">
<span className="text-blue-600 dark:text-blue-400 font-black text-xs">{idx + 1}</span>
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">{module.title}</h2>
</div>
<div className="grid gap-3 pl-14">
{module.lessons.map((lesson: any) => {
const locked = isLessonLocked(lesson.id);
const isPreviewable = lesson.is_previewable;
return (isEnrolled || isPreviewable) ? (
locked ? (
<div key={lesson.id} className="glass-card !p-4 border-black/5 dark:border-white/5 opacity-60 cursor-not-allowed">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center">
<Lock size={18} className="text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-500 dark:text-gray-400">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-600">Bloqueado por Prerrequisitos</span>
</div>
</div>
<div className="flex items-center gap-6">
<Lock size={18} className="text-gray-400 dark:text-gray-600" />
</div>
</div>
</div>
) : (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-black/5 dark:hover:bg-white/10 border-black/5 dark:border-white/5 active:scale-[0.99] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center group-hover:bg-blue-600/10 dark:group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className={`${isPreviewable && !isEnrolled ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'} group-hover:text-blue-600 dark:group-hover:text-blue-400`} />
) : (
<BookOpen size={18} className={`${isPreviewable && !isEnrolled ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'} group-hover:text-blue-600 dark:group-hover:text-blue-400`} />
)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-gray-700 dark:text-gray-200 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">{lesson.title}</h3>
{isPreviewable && !isEnrolled && (
<span className="text-[8px] font-black uppercase px-1.5 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20 rounded">Vista previa</span>
)}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</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>
</Link>
)
) : (
<div key={lesson.id} onClick={handleEnrollOrBuy} className="glass-card !p-4 group border-black/5 dark:border-white/5 opacity-60 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center">
<Clock size={18} className="text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-500 dark:text-gray-400">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-600 flex items-center gap-1">
Contenido Protegido
</span>
</div>
</div>
<div className="flex items-center gap-2 text-gray-400 dark:text-gray-600 font-bold text-[10px] uppercase tracking-widest">
<span>Bloqueado</span>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
{/* Discussions Section */}
<div className="mt-20">
<DiscussionBoard courseId={params.id} />
</div>
</div>
);
@@ -0,0 +1,174 @@
"use client";
import React from "react";
import { Course, getImageUrl } from "@/lib/api";
import {
Target,
Zap,
Clock,
Award,
CheckCircle2,
Info,
Calendar,
Users
} from "lucide-react";
interface AboutCourseProps {
course: Course;
instructors: any[];
}
export default function AboutCourse({ course, instructors }: AboutCourseProps) {
const meta = course.marketing_metadata || {};
return (
<div className="space-y-24 pb-20 animate-in fade-in duration-1000">
{/* ── HERO GRID ── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
<div className="lg:col-span-12">
<div className="relative aspect-[21/9] rounded-[3rem] overflow-hidden border border-slate-200 dark:border-white/5 shadow-2xl group">
{course.course_image_url ? (
<img
src={getImageUrl(course.course_image_url)}
alt={course.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-[4s]"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 to-indigo-900 flex items-center justify-center">
<h1 className="text-4xl font-black text-white/20 uppercase tracking-[0.5em]">{course.title}</h1>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute bottom-12 left-12 right-12">
<div className="flex items-center gap-4 mb-4">
<span className="px-4 py-1.5 bg-blue-600/20 backdrop-blur-md border border-blue-500/30 text-blue-400 rounded-full text-[10px] font-black uppercase tracking-widest shadow-xl shadow-blue-500/20">
Official Curriculum
</span>
{course.pacing_mode && (
<span className="px-4 py-1.5 bg-white/10 backdrop-blur-md border border-white/10 text-white rounded-full text-[10px] font-black uppercase tracking-widest">
{course.pacing_mode.replace("_", " ")}
</span>
)}
</div>
<h1 className="text-5xl lg:text-7xl font-black text-white tracking-tighter drop-shadow-2xl">{course.title}</h1>
</div>
</div>
</div>
</div>
{/* ── MANIFESTO GRID ── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-16">
{/* Right: Core Meta */}
<div className="lg:col-span-1 space-y-12">
<div className="glass-premium p-10 rounded-[2.5rem] border-white/5 space-y-8 shadow-xl">
<div className="space-y-2">
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">Key Information</span>
<div className="flex flex-col gap-6 pt-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-500 border border-blue-500/20">
<Clock size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500 dark:text-gray-500">Duration</p>
<p className="text-sm font-black text-slate-900 dark:text-white">{meta.duration || "Self-paced immersion"}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-500 border border-indigo-500/20">
<Award size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500 dark:text-gray-500">Certification</p>
<p className="text-sm font-black text-slate-900 dark:text-white uppercase tracking-tight">{meta.certification_info || "Accredited Certificate"}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20">
<Zap size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500 dark:text-gray-500">Passing Grade</p>
<p className="text-sm font-black text-slate-900 dark:text-white">{course.passing_percentage}% Proficiency</p>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.4em] text-slate-400 dark:text-gray-500 flex items-center gap-3">
<Users size={16} /> Course Faculty
</h4>
<div className="space-y-4">
{instructors.map((inst) => (
<div key={inst.id} className="flex items-center gap-5 p-5 glass-premium rounded-[1.5rem] border-white/5 hover:bg-white/10 transition-all group">
<div className="w-14 h-14 rounded-[1.25rem] bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center text-white text-xl font-black shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-transform">
{inst.full_name?.charAt(0) || inst.email?.charAt(0)}
</div>
<div>
<h5 className="font-black text-slate-900 dark:text-white tracking-tight">{inst.full_name}</h5>
<p className="text-[10px] font-black uppercase tracking-widest text-blue-500 mt-1">{inst.role || "Instructor"}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Left: Objectives & Manifesto */}
<div className="lg:col-span-2 space-y-16">
<section className="space-y-8">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center shadow-lg shadow-blue-600/30">
<Target size={20} />
</div>
<h3 className="text-2xl font-black uppercase tracking-tighter text-slate-900 dark:text-white">Learning Objectives</h3>
</div>
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg leading-relaxed text-slate-600 dark:text-gray-400 font-medium italic">
{meta.objectives || "Comprehensive learning path designed to master core concepts and advanced applications."}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-8">
<div className="p-8 bg-slate-50 dark:bg-white/5 rounded-[2rem] border border-slate-100 dark:border-white/5 space-y-4">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-amber-500 flex items-center gap-2">
<Zap size={14} /> Prerequisites
</h4>
<p className="text-sm font-bold text-slate-700 dark:text-gray-300 leading-relaxed">
{meta.requirements || "No specific prerequisites required. An open mind and dedication to learning are the only things needed to succeed."}
</p>
</div>
<div className="p-8 bg-indigo-50/50 dark:bg-indigo-500/5 rounded-[2rem] border border-indigo-100/50 dark:border-indigo-500/10 space-y-4">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-indigo-500 flex items-center gap-2">
<Info size={14} /> Curriculum Insight
</h4>
<p className="text-sm font-bold text-indigo-900/60 dark:text-indigo-400/60 leading-relaxed">
Each module is structured to provide both theoretical knowledge and practical applications in real-world scenarios.
</p>
</div>
</div>
</section>
<section className="space-y-8 border-t border-slate-100 dark:border-white/5 pt-16">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center shadow-lg shadow-indigo-600/30">
<CheckCircle2 size={20} />
</div>
<h3 className="text-2xl font-black uppercase tracking-tighter text-slate-900 dark:text-white">The Modules Deep-Dive</h3>
</div>
<div className="p-10 glass-premium rounded-[2.5rem] border-white/5 bg-gradient-to-br from-white/5 to-transparent">
<p className="text-lg leading-relaxed text-slate-600 dark:text-gray-400 font-medium">
{meta.modules_summary || "Explore the rich content structured across multiple interactive modules. Each section builds upon the previous one to ensure a cohesive learning experience."}
</p>
</div>
</section>
</div>
</div>
</div>
);
}
@@ -9,7 +9,7 @@ interface MediaPlayerProps {
lessonId?: string;
title?: string;
url: string;
media_type: 'video' | 'audio';
media_type: 'video' | 'audio' | 'image';
config?: {
maxPlays?: number;
show_transcript?: boolean;
@@ -112,6 +112,11 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
}
// Helper to format URL (handles YouTube embeds)
const isYouTube = url.includes("youtube.com") || url.includes("youtu.be");
const isVimeo = url.includes("vimeo.com");
const isImageFile = url.match(/\.(jpeg|jpg|gif|png|webp|avif)$/i);
const imageType = media_type === 'image' || isImageFile;
const getEmbedUrl = (rawUrl: string) => {
if (rawUrl.includes("youtube.com/watch?v=")) {
return rawUrl.replace("watch?v=", "embed/");
@@ -144,7 +149,13 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
</div>
<div className="glass-card !p-2 overflow-hidden aspect-video relative group">
{isLocalFile ? (
{imageType ? (
<img
src={getFullUrl(url)}
alt={title || "Lesson Image"}
className="w-full h-full object-contain rounded-xl"
/>
) : isLocalFile ? (
<video
src={getFullUrl(url)}
controls
+8
View File
@@ -58,6 +58,14 @@ export interface Course {
end_date?: string;
price: number;
currency: string;
marketing_metadata?: {
objectives?: string;
requirements?: string;
duration?: string;
modules_summary?: string;
certification_info?: string;
};
course_image_url?: string;
created_at: string;
}
+1 -1
View File
@@ -12,7 +12,7 @@ RUN cargo build --release -p cms-service
FROM node:18-alpine AS node-builder
WORKDIR /app
COPY web/studio/package*.json ./
RUN npm install
RUN npm ci
COPY web/studio/ .
ARG NEXT_PUBLIC_CMS_API_URL
ENV NEXT_PUBLIC_CMS_API_URL=$NEXT_PUBLIC_CMS_API_URL
+26 -6
View File
@@ -53,16 +53,36 @@ export default function BackgroundTasksPage() {
}
};
const getStatusBadge = (status?: string) => {
const getStatusBadge = (task: BackgroundTask) => {
const status = task.video_generation_status || task.transcription_status;
const progress = task.generation_progress || 0;
switch (status) {
case 'processing':
return <span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-semibold flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> Processing</span>;
return (
<div className="flex flex-col gap-2">
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-semibold flex items-center gap-1 w-fit">
<Loader2 className="w-3 h-3 animate-spin" /> Processing
</span>
{task.video_generation_status === 'processing' && (
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1 max-w-[150px]">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
></div>
<div className="text-[10px] text-gray-400 mt-1 font-medium">{progress}% completo</div>
</div>
)}
</div>
);
case 'queued':
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold">Queued</span>;
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>;
case 'failed':
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold">Failed</span>;
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>;
case 'completed':
return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>;
default:
return <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-semibold">{status}</span>;
return <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">{status}</span>;
}
};
@@ -109,7 +129,7 @@ export default function BackgroundTasksPage() {
<div className="text-xs text-gray-400 font-mono mt-1">{task.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(task.transcription_status)}
{getStatusBadge(task)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{format(new Date(task.updated_at), 'MMM d, h:mm a')}
@@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency } from '@/lib/api';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl } from '@/lib/api';
import {
Layout,
CheckCircle2,
@@ -40,6 +40,7 @@ import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
import LibraryPanel from "@/components/LibraryPanel";
import Modal from "@/components/Modal";
import MediaPlayer from "@/components/MediaPlayer";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const [lesson, setLesson] = useState<Lesson | null>(null);
@@ -1152,6 +1153,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div>
))}
{!editMode && blocks.length === 0 && lesson.content_url && (
<div className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100 mb-12">
<MediaPlayer
src={getImageUrl(lesson.content_url)}
type={lesson.content_type || 'video'}
transcription={lesson.transcription}
/>
<div className="mt-6 p-6 glass-card bg-blue-500/5 border-blue-500/10 text-center rounded-[2rem]">
<p className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
AI Generated Content Preview
</p>
</div>
</div>
)}
{editMode && (
<div className="pt-20 border-t border-slate-100 dark:border-white/5">
<div className="flex flex-col items-center gap-12">
@@ -0,0 +1,363 @@
"use client";
import React, { useState, useEffect } from "react";
import {
cmsApi,
Course,
getImageUrl
} from "@/lib/api";
import {
Save,
Sparkles,
Image as ImageIcon,
Type,
Target,
AlertCircle,
CheckCircle2,
Clock,
Award,
Zap,
Maximize,
Monitor,
Square,
Smartphone
} from "lucide-react";
interface MarketingTabProps {
courseId: string;
}
const RESOLUTIONS = [
{ label: "16:9 Landscape", width: 1024, height: 576, icon: Monitor },
{ label: "1:1 Square", width: 1024, height: 1024, icon: Square },
{ label: "4:3 Classic", width: 1024, height: 768, icon: Maximize },
{ label: "9:16 Portrait", width: 576, height: 1024, icon: Smartphone },
];
export default function MarketingTab({ courseId }: MarketingTabProps) {
const [course, setCourse] = useState<Course | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [prompt, setPrompt] = useState("");
const [selectedRes, setSelectedRes] = useState(RESOLUTIONS[0]);
const [isGenerating, setIsGenerating] = useState(false);
// Form states
const [objectives, setObjectives] = useState("");
const [requirements, setRequirements] = useState("");
const [duration, setDuration] = useState("");
const [modulesSummary, setModulesSummary] = useState("");
const [certificationInfo, setCertificationInfo] = useState("");
const loadCourse = async () => {
try {
setLoading(true);
const data = await cmsApi.getCourse(courseId);
setCourse(data);
// Initialize form from metadata
const meta = data.marketing_metadata || {};
setObjectives(meta.objectives || "");
setRequirements(meta.requirements || "");
setDuration(meta.duration || "");
setModulesSummary(meta.modules_summary || "");
setCertificationInfo(meta.certification_info || "");
if (data.generation_status === 'processing' || data.generation_status === 'queued') {
setIsGenerating(true);
} else {
setIsGenerating(false);
}
} catch (err) {
console.error("Failed to load course", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCourse();
}, [courseId]);
// Polling for generation status
useEffect(() => {
let interval: NodeJS.Timeout;
if (isGenerating) {
interval = setInterval(async () => {
const updated = await cmsApi.getCourse(courseId);
setCourse(updated);
if (updated.generation_status === 'completed' || updated.generation_status === 'error') {
setIsGenerating(false);
clearInterval(interval);
}
}, 3000);
}
return () => clearInterval(interval);
}, [isGenerating, courseId]);
const handleSaveMetadata = async () => {
try {
setSaving(true);
await cmsApi.updateCourse(courseId, {
marketing_metadata: {
objectives,
requirements,
duration,
modules_summary: modulesSummary,
certification_info: certificationInfo
}
});
alert("Marketing metadata saved successfully!");
} catch (err) {
console.error("Save failed", err);
alert("Failed to save changes.");
} finally {
setSaving(false);
}
};
const handleGenerateImage = async () => {
try {
setIsGenerating(true);
await cmsApi.generateCourseImage(courseId, {
prompt: prompt || course?.title,
width: selectedRes.width,
height: selectedRes.height
});
} catch (err) {
console.error("Generation failed", err);
alert("Failed to start generation.");
setIsGenerating(false);
}
};
if (loading) return (
<div className="flex items-center justify-center py-20">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
</div>
);
return (
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* ── SECTION: COURSE IMAGE AI ── */}
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] overflow-hidden shadow-sm hover:shadow-xl transition-all duration-500">
<div className="p-10 lg:p-14 flex flex-col lg:flex-row gap-12">
{/* Left: Preview */}
<div className="lg:w-1/2 space-y-6">
<div className="group relative aspect-video rounded-[2.5rem] bg-slate-100 dark:bg-black/20 overflow-hidden border border-slate-200 dark:border-white/5 shadow-inner">
{course?.course_image_url ? (
<img
src={getImageUrl(course.course_image_url)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-[2s]"
alt="Course Preview"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400 gap-4">
<ImageIcon size={64} className="opacity-20 stroke-[1]" />
<p className="text-[10px] font-black uppercase tracking-[0.3em] opacity-40">No preview generated</p>
</div>
)}
{isGenerating && (
<div className="absolute inset-0 bg-white/60 dark:bg-black/80 backdrop-blur-md flex flex-col items-center justify-center p-12 text-center">
<Zap size={48} className="text-blue-500 animate-pulse mb-6" />
<h4 className="text-xl font-black uppercase tracking-tight text-slate-900 dark:text-white mb-4">Generating Visual Intelligence...</h4>
<div className="w-full h-3 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mb-4 border border-white/10">
<div
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-500 ease-out"
style={{ width: `${course?.generation_progress || 0}%` }}
/>
</div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-blue-600 dark:text-blue-400">
Analysis Phase: {course?.generation_progress || 0}% Complete
</p>
</div>
)}
</div>
{course?.generation_error && (
<div className="p-6 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-3xl flex items-center gap-4 text-red-600 dark:text-red-400">
<AlertCircle size={24} />
<div className="flex-1">
<p className="text-[10px] font-black uppercase tracking-widest mb-1">Neural Engine Error</p>
<p className="text-sm font-medium">{course.generation_error}</p>
</div>
</div>
)}
</div>
{/* Right: Controls */}
<div className="lg:w-1/2 space-y-10">
<div>
<h3 className="text-3xl font-black uppercase tracking-tighter text-slate-900 dark:text-white flex items-center gap-4">
<Sparkles className="text-blue-500" />
AI Visual Identity
</h3>
<p className="text-slate-500 dark:text-gray-500 mt-2 font-medium">Generate a cinematic landing page image using Stable Diffusion.</p>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2">Creative Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={course?.title || "Describe the visual mood of your course..."}
className="w-full h-32 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[2rem] p-6 text-slate-900 dark:text-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 transition-all resize-none font-medium shadow-inner"
/>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2">Resolution Matrix</label>
<div className="grid grid-cols-2 gap-4">
{RESOLUTIONS.map((res) => {
const Icon = res.icon;
const isSelected = selectedRes.label === res.label;
return (
<button
key={res.label}
onClick={() => setSelectedRes(res)}
className={`p-5 rounded-3xl border transition-all flex items-center gap-4 group ${isSelected
? "bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-500/20 active:scale-95"
: "bg-white dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-600 dark:text-gray-400 hover:bg-slate-50 dark:hover:bg-white/10 active:scale-95 shadow-sm"
}`}
>
<div className={`p-2 rounded-xl ${isSelected ? "bg-white/20" : "bg-slate-100 dark:bg-white/10 group-hover:scale-110 transition-transform"}`}>
<Icon size={20} />
</div>
<div className="text-left">
<p className="text-xs font-black uppercase tracking-tight">{res.label}</p>
<p className={`text-[10px] font-black opacity-60 ${isSelected ? "text-white" : "text-slate-400"}`}>{res.width}x{res.height}</p>
</div>
</button>
);
})}
</div>
</div>
<button
onClick={handleGenerateImage}
disabled={isGenerating}
className={`w-full py-5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-[2rem] font-black text-[10px] uppercase tracking-[0.3em] shadow-2xl transition-all active:scale-[0.98] flex items-center justify-center gap-4 group ${isGenerating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02]'}`}
>
{isGenerating ? (
<>
<div className="w-5 h-5 border-2 border-slate-300 border-t-blue-500 rounded-full animate-spin" />
Synthesizing...
</>
) : (
<>
<Sparkles size={20} className="group-hover:rotate-12 transition-transform" />
Energize Neural Engine
</>
)}
</button>
</div>
</div>
</div>
{/* ── SECTION: CORE MARKETING DATA ── */}
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] p-10 lg:p-14 space-y-12 shadow-sm">
<div className="flex items-center justify-between gap-6 flex-wrap">
<div>
<h3 className="text-3xl font-black uppercase tracking-tighter text-slate-900 dark:text-white flex items-center gap-4">
<Megaphone className="text-indigo-500" />
Marketing Manifesto
</h3>
<p className="text-slate-500 dark:text-gray-500 mt-2 font-medium">Define the core value proposition and educational objectives.</p>
</div>
<button
onClick={handleSaveMetadata}
disabled={saving}
className="flex items-center gap-3 px-10 py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-[1.5rem] font-black text-[10px] uppercase tracking-[0.2em] shadow-xl shadow-indigo-500/30 transition-all active:scale-95 disabled:opacity-50"
>
{saving ? <div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save size={18} />}
{saving ? 'Saving Manifest...' : 'Update Manifesto'}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
<div className="space-y-6">
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400 ml-2 flex items-center gap-2">
<Target size={14} /> Learning Objectives
</label>
<textarea
value={objectives}
onChange={(e) => setObjectives(e.target.value)}
placeholder="What will the student achieve?"
className="w-full h-40 bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded-[2rem] p-6 text-slate-800 dark:text-white focus:outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all resize-none font-medium shadow-inner"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-amber-500 ml-2 flex items-center gap-2">
<Zap size={14} /> Requirements & Prerequisites
</label>
<textarea
value={requirements}
onChange={(e) => setRequirements(e.target.value)}
placeholder="What should they know before starting?"
className="w-full h-40 bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded-[2rem] p-6 text-slate-800 dark:text-white focus:outline-none focus:ring-4 focus:ring-amber-500/10 transition-all resize-none font-medium shadow-inner"
/>
</div>
</div>
<div className="space-y-8">
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2 flex items-center gap-2">
<Clock size={14} /> Estimated Duration
</label>
<input
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="e.g. 10 weeks, 40 hours"
className="w-full bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded-[1.5rem] px-6 py-4 text-slate-800 dark:text-white focus:outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all font-black uppercase tracking-tight shadow-inner"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2 flex items-center gap-2">
<Award size={14} /> Certification Info
</label>
<textarea
value={certificationInfo}
onChange={(e) => setCertificationInfo(e.target.value)}
placeholder="Details about the final certificate or credential."
className="w-full h-32 bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded-[2rem] p-6 text-slate-800 dark:text-white focus:outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all resize-none font-medium shadow-inner"
/>
</div>
<div className="p-8 bg-blue-50 dark:bg-blue-500/5 rounded-[2.5rem] border border-blue-100 dark:border-blue-500/10">
<div className="flex items-center gap-4 mb-4">
<div className="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center shadow-lg shadow-blue-500/30">
<AlertCircle size={20} />
</div>
<h4 className="font-black uppercase tracking-tight text-blue-900 dark:text-blue-300">Public Preview</h4>
</div>
<p className="text-sm text-blue-700/70 dark:text-blue-400/70 font-medium leading-relaxed">
This information will be displayed on the public landing page. Use compelling language to increase student enrollment.
</p>
</div>
</div>
</div>
<div className="space-y-4 pt-6">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2 flex items-center gap-2">
<Type size={14} /> Modules Deep-Dive (Sales Summary)
</label>
<textarea
value={modulesSummary}
onChange={(e) => setModulesSummary(e.target.value)}
placeholder="Highlight the key modules and what's unique about them."
className="w-full h-40 bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded-[2rem] p-8 text-slate-800 dark:text-white focus:outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all resize-none font-medium shadow-inner"
/>
</div>
</div>
</div>
);
}
import { Megaphone } from "lucide-react";
@@ -0,0 +1,16 @@
"use client";
import CourseEditorLayout from "@/components/CourseEditorLayout";
import MarketingTab from "./MarketingTab";
export default function MarketingPage({ params }: { params: { id: string } }) {
return (
<CourseEditorLayout
activeTab="marketing"
pageTitle="Marketing del Curso"
pageDescription="Configura la landing page y genera activos visuales con IA."
>
<MarketingTab courseId={params.id} />
</CourseEditorLayout>
);
}
@@ -12,6 +12,7 @@ import { cmsApi, Course } from "@/lib/api";
type TabKey =
| "outline"
| "marketing"
| "grading"
| "rubrics"
| "calendar"
@@ -73,6 +74,7 @@ export default function CourseEditorLayout({
icon: BookOpen,
tabs: [
{ key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` },
{ key: "marketing", label: "Marketing", icon: Megaphone, href: `/courses/${id}/marketing` },
{ key: "files", label: "Archivos", icon: Folder, href: `/courses/${id}/files` },
{ key: "sessions", label: "Sesiones en Vivo", icon: Video, href: `/courses/${id}/sessions` },
],
+12
View File
@@ -151,6 +151,18 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded,
);
}
if (type === "image") {
return (
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
<img
src={src}
alt="Lesson Content"
className="w-full aspect-video object-contain"
/>
</div>
);
}
return (
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
{type === "video" ? (
@@ -16,7 +16,7 @@ interface MediaBlockProps {
id: string;
title?: string;
url: string;
type: 'video' | 'audio';
type: 'video' | 'audio' | 'image';
config: {
maxPlays?: number;
currentPlays?: number;
@@ -44,7 +44,9 @@ interface MediaBlockProps {
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription, isGraded }: MediaBlockProps) {
const [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
const [sourceType, setSourceType] = useState<"url" | "upload">(
(url.startsWith("/assets/") || url.includes("/assets/")) ? "upload" : "url"
);
const maxPlays = config.maxPlays || 0;
const isLocked = maxPlays > 0 && localPlays >= maxPlays;
+19 -3
View File
@@ -14,9 +14,12 @@ export const LMS_API_BASE_URL = getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LM
export const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
// Map /uploads to /assets if backend stores relative paths
// Map uploads to assets if backend stores relative paths
// The main.rs serves "uploads" dir at "/assets" route
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
let cleanPath = path;
if (cleanPath.startsWith('uploads/')) cleanPath = '/' + cleanPath.replace('uploads/', 'assets/');
if (cleanPath.startsWith('/uploads/')) cleanPath = cleanPath.replace('/uploads/', '/assets/');
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${API_BASE_URL}${finalPath}`;
};
@@ -34,6 +37,17 @@ export interface Course {
certificate_template?: string;
price: number;
currency: string;
marketing_metadata?: {
objectives?: string;
requirements?: string;
duration?: string;
modules_summary?: string;
certification_info?: string;
};
course_image_url?: string;
generation_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'error';
generation_progress?: number;
generation_error?: string;
created_at: string;
updated_at: string;
modules?: Module[];
@@ -632,7 +646,8 @@ export const cmsApi = {
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
generateImage: (id: string, payload: { prompt?: string } = {}): Promise<Lesson> => apiFetch(`/lessons/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
generateImage: (id: string, payload: { prompt?: string, width?: number, height?: number } = {}): Promise<Lesson> => apiFetch(`/lessons/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
generateCourseImage: (id: string, payload: { prompt?: string, width?: number, height?: number } = {}): Promise<Course> => apiFetch(`/courses/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
@@ -961,5 +976,6 @@ export interface BackgroundTask {
course_title?: string;
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
video_generation_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
generation_progress?: number;
updated_at: string;
}