feat: Introduce course marketing features with dedicated metadata, image generation, and UI in both studio and experience apps.
This commit is contained in:
@@ -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.).
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **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**:
|
- **IA Local**:
|
||||||
- **Faster-Whisper**: Transcripción de audio a texto.
|
- **Faster-Whisper**: Transcripción de audio a texto.
|
||||||
- **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios.
|
- **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.
|
- **i18n Infrastructure**: Sistema de traducción reactivo para soporte global.
|
||||||
- **Document Management**: Motor de previsualización de documentos PDF nativo.
|
- **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 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.
|
- **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.
|
- **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)
|
## �️ Próximos Pasos (Roadmap 2024-2025)
|
||||||
|
|
||||||
|
|||||||
+53
-19
@@ -5,16 +5,26 @@
|
|||||||
# 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
|
# 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
|
||||||
# 2. Hardware detection (NVIDIA GPU vs CPU)
|
# 2. Hardware detection (NVIDIA GPU vs CPU)
|
||||||
# 3. Environment configuration (.env)
|
# 3. Environment configuration (.env)
|
||||||
# 4. Database creation and migrations
|
# 4. Database creation and migrations (CMS, LMS, AI Bridge)
|
||||||
# 5. System initialization (Admin account)
|
# 5. System initialization (Admin account and Organization)
|
||||||
|
# Version: 1.5 - AI Marketing & High-Res Support
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "===================================================="
|
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 "===================================================="
|
||||||
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
|
# 1. Detección y Clonación
|
||||||
if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then
|
if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then
|
||||||
echo "✅ Proyecto detectado en el directorio actual."
|
echo "✅ Proyecto detectado en el directorio actual."
|
||||||
@@ -31,17 +41,18 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Prerequisite Installation
|
if [ "$FAST_MODE" == "false" ]; then
|
||||||
install_pkg() {
|
# 2. Prerequisite Installation
|
||||||
|
install_pkg() {
|
||||||
if ! command -v "$1" &> /dev/null; then
|
if ! command -v "$1" &> /dev/null; then
|
||||||
echo "🔧 Instalando $1..."
|
echo "🔧 Instalando $1..."
|
||||||
apt-get update && apt-get install -y "$1"
|
apt-get update && apt-get install -y "$1"
|
||||||
else
|
else
|
||||||
echo "✅ $1 ya está instalado."
|
echo "✅ $1 ya está instalado."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
if [ -f /etc/debian_version ]; then
|
if [ -f /etc/debian_version ]; then
|
||||||
install_pkg "curl"
|
install_pkg "curl"
|
||||||
install_pkg "git"
|
install_pkg "git"
|
||||||
@@ -52,25 +63,26 @@ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
|||||||
install_pkg "docker-compose-v2"
|
install_pkg "docker-compose-v2"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v cargo &> /dev/null; then
|
if ! command -v cargo &> /dev/null; then
|
||||||
echo "🔧 Instalando Rust..."
|
echo "🔧 Instalando Rust..."
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
source $HOME/.cargo/env
|
source $HOME/.cargo/env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "🔧 Instalando Node.js vía NVM..."
|
echo "🔧 Instalando Node.js vía NVM..."
|
||||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
export NVM_DIR="$HOME/.nvm"
|
export NVM_DIR="$HOME/.nvm"
|
||||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
nvm install --lts
|
nvm install --lts
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v sqlx &> /dev/null; then
|
if ! command -v sqlx &> /dev/null; then
|
||||||
echo "🔧 Instalando sqlx-cli..."
|
echo "🔧 Instalando sqlx-cli..."
|
||||||
cargo install sqlx-cli --no-default-features --features postgres
|
cargo install sqlx-cli --no-default-features --features postgres
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Environment Configuration
|
# 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}
|
REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA}
|
||||||
read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL
|
read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL
|
||||||
REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER}
|
REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER}
|
||||||
read -p "Ingrese la URL del Video Bridge Remoto [http://t-800:8080]: " REMOTE_VIDEO_URL
|
read -p "Ingrese la URL del Image Bridge Remoto [http://t-800:8080]: " REMOTE_IMAGE_URL
|
||||||
REMOTE_VIDEO_URL=${REMOTE_VIDEO_URL:-"http://t-800:8080"}
|
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
|
read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL
|
||||||
LLM_MODEL=${LLM_MODEL:-llama3.2:3b}
|
LLM_MODEL=${LLM_MODEL:-llama3.2:3b}
|
||||||
|
|
||||||
update_env "AI_PROVIDER" "local"
|
update_env "AI_PROVIDER" "local"
|
||||||
update_env "LOCAL_LLM_MODEL" "$LLM_MODEL"
|
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
|
if [ "$ENV_CHOICE" == "dev" ]; then
|
||||||
update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL"
|
update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL"
|
||||||
@@ -140,6 +152,16 @@ else
|
|||||||
update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL"
|
update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL"
|
||||||
fi
|
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.
|
# AI setup is now purely remote. Skipping local container configuration.
|
||||||
|
|
||||||
# Solicitar credenciales de DB si no están configuradas
|
# Solicitar credenciales de DB si no están configuradas
|
||||||
@@ -219,9 +241,21 @@ if [ "$ADMIN_EXISTS" != "t" ]; then
|
|||||||
ORG_NAME="Default Organization"
|
ORG_NAME="Default Organization"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
# Selective Build/Rebuild
|
||||||
echo "🚀 Iniciando todos los servicios..."
|
if [ "$FAST_MODE" == "true" ]; then
|
||||||
docker compose up -d --build
|
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
|
if [ "$ADMIN_EXISTS" != "t" ]; then
|
||||||
echo "⏳ Esperando a que el API CMS esté listo..."
|
echo "⏳ Esperando a que el API CMS esté listo..."
|
||||||
|
|||||||
+8
-2
@@ -218,11 +218,17 @@
|
|||||||
- [x] Perfiles profesionales públicos con control de privacidad.
|
- [x] Perfiles profesionales públicos con control de privacidad.
|
||||||
- [x] Visualización de progreso y nivel de XP.
|
- [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**:
|
**Próximas Prioridades**:
|
||||||
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
||||||
2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos.
|
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 |
@@ -3,7 +3,7 @@ import time
|
|||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import torch
|
import torch
|
||||||
from diffusers import StableDiffusionPipeline
|
from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -20,14 +20,20 @@ pipe = None
|
|||||||
|
|
||||||
def load_model():
|
def load_model():
|
||||||
global pipe
|
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(
|
pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
MODEL_ID,
|
MODEL_ID,
|
||||||
torch_dtype=torch.float32,
|
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
|
||||||
)
|
)
|
||||||
pipe.to("cpu")
|
# Use a high-quality scheduler for better detail
|
||||||
# pipe.enable_model_cpu_offload()
|
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
|
||||||
|
pipe.to(device)
|
||||||
|
|
||||||
|
if device == "cpu":
|
||||||
pipe.enable_attention_slicing()
|
pipe.enable_attention_slicing()
|
||||||
|
|
||||||
print("Model loaded successfully.")
|
print("Model loaded successfully.")
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -43,9 +49,16 @@ app = FastAPI(lifespan=lifespan)
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
|
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
class ImageRequest(BaseModel):
|
class ImageRequest(BaseModel):
|
||||||
prompt: str
|
prompt: str
|
||||||
lesson_id: 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")
|
@app.post("/generate")
|
||||||
async def generate_image(request: ImageRequest):
|
async def generate_image(request: ImageRequest):
|
||||||
@@ -53,24 +66,66 @@ async def generate_image(request: ImageRequest):
|
|||||||
if pipe is None:
|
if pipe is None:
|
||||||
load_model()
|
load_model()
|
||||||
|
|
||||||
try:
|
num_steps = 150
|
||||||
print(f"Generating image for prompt: {request.prompt}")
|
|
||||||
|
|
||||||
generator = torch.manual_seed(42)
|
def progress_callback(step: int, timestep: int, latents: torch.FloatTensor):
|
||||||
# Using a small number of steps for speed since it's on CPU
|
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:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Generation with custom resolution
|
||||||
image = pipe(
|
image = pipe(
|
||||||
request.prompt,
|
quality_prompt,
|
||||||
num_inference_steps=20,
|
negative_prompt=negative_prompt,
|
||||||
generator=generator
|
num_inference_steps=num_steps,
|
||||||
|
guidance_scale=8.5,
|
||||||
|
width=request.width,
|
||||||
|
height=request.height,
|
||||||
|
callback_on_step_end=callback_dynamic_cfg
|
||||||
).images[0]
|
).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_filename = f"image_{request.lesson_id}_{uuid.uuid4().hex[:8]}.png"
|
||||||
image_path = os.path.join(OUTPUT_DIR, image_filename)
|
image_path = os.path.join(OUTPUT_DIR, image_filename)
|
||||||
|
|
||||||
image.save(image_path)
|
image.save(image_path)
|
||||||
|
|
||||||
# Return the absolute URL pointing to t-800 so the frontend can find it
|
# 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}"
|
full_url = f"http://{hostname}:8080/outputs/{image_filename}"
|
||||||
|
|
||||||
return {"status": "completed", "url": full_url}
|
return {"status": "completed", "url": full_url}
|
||||||
|
|||||||
@@ -408,6 +408,17 @@ pub async fn update_course(
|
|||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or(existing.currency);
|
.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
|
// BEGIN TRANSACTION
|
||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
@@ -425,7 +436,7 @@ pub async fn update_course(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
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(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -438,6 +449,8 @@ pub async fn update_course(
|
|||||||
.bind(certificate_template)
|
.bind(certificate_template)
|
||||||
.bind(price)
|
.bind(price)
|
||||||
.bind(currency)
|
.bind(currency)
|
||||||
|
.bind(marketing_metadata)
|
||||||
|
.bind(course_image_url)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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 filename = url.trim_start_matches("/assets/");
|
||||||
let file_path = format!("uploads/{}", filename);
|
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);
|
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)
|
.bind(lesson_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.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
|
// 3. Read file
|
||||||
let file_data = tokio::fs::read(&file_path)
|
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
|
// 6. Update lesson with bilinguial transcription - ONLY if not cancelled (idle)
|
||||||
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2")
|
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2 AND transcription_status = 'processing'")
|
||||||
.bind(&transcription_result)
|
.bind(&transcription_result)
|
||||||
.bind(lesson_id)
|
.bind(lesson_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -1286,6 +1305,8 @@ pub async fn generate_quiz(
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct VideoAIRequest {
|
pub struct VideoAIRequest {
|
||||||
pub prompt: Option<String>,
|
pub prompt: Option<String>,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_image(
|
pub async fn generate_image(
|
||||||
@@ -1334,8 +1355,11 @@ pub async fn generate_image(
|
|||||||
// 3. Spawn background task
|
// 3. Spawn background task
|
||||||
let pool_clone = pool.clone();
|
let pool_clone = pool.clone();
|
||||||
let prompt_to_task = payload.prompt.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 {
|
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);
|
tracing::error!("Image generation task failed for lesson {}: {}", id, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1343,36 +1367,123 @@ pub async fn generate_image(
|
|||||||
Ok(Json(updated_lesson))
|
Ok(Json(updated_lesson))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_prompt: Option<String>) -> Result<(), String> {
|
pub async fn generate_course_image(
|
||||||
// 1. Set status to processing
|
Org(org_ctx): Org,
|
||||||
sqlx::query("UPDATE lessons SET video_generation_status = 'processing' WHERE id = $1")
|
claims: common::auth::Claims,
|
||||||
.bind(lesson_id)
|
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)
|
.execute(&pool)
|
||||||
.await
|
.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)
|
// 2. Call Local Video Bridge (Python)
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL")
|
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);
|
let bridge_url = format!("{}/generate", bridge_base_url);
|
||||||
|
|
||||||
// Fallback logic for prompt: Custom Prompt > Title
|
// Fallback logic for prompt: Custom Prompt > Title
|
||||||
let final_prompt = match custom_prompt {
|
let final_prompt = match custom_prompt {
|
||||||
Some(p) if !p.is_empty() => p,
|
Some(p) if !p.is_empty() => p,
|
||||||
_ => {
|
_ => {
|
||||||
sqlx::query_scalar("SELECT title FROM lessons WHERE id = $1")
|
let title: String = sqlx::query_scalar(&format!("SELECT title FROM {} WHERE id = $1", table_name))
|
||||||
.bind(lesson_id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.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)
|
let response = client.post(bridge_url)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"prompt": final_prompt,
|
"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()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1380,24 +1491,108 @@ pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_pro
|
|||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let err_text = response.text().await.unwrap_or_default();
|
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));
|
return Err(format!("Video bridge error: {}", err_text));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: serde_json::Value = response.json().await
|
let result: serde_json::Value = response.json().await
|
||||||
.map_err(|e| format!("Failed to parse video bridge response: {}", e))?;
|
.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())?;
|
.ok_or_else(|| "Video bridge response missing URL".to_string())?;
|
||||||
|
|
||||||
// 3. Complete task
|
// --- Download image and store as Asset ---
|
||||||
sqlx::query(
|
|
||||||
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2"
|
// 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(content_url)
|
.bind(id)
|
||||||
.bind(lesson_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(
|
||||||
|
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(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)
|
.execute(&pool)
|
||||||
.await
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub struct BackgroundTask {
|
|||||||
pub course_title: Option<String>,
|
pub course_title: Option<String>,
|
||||||
pub transcription_status: Option<String>,
|
pub transcription_status: Option<String>,
|
||||||
pub video_generation_status: Option<String>,
|
pub video_generation_status: Option<String>,
|
||||||
|
pub generation_progress: Option<i32>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ pub async fn get_background_tasks(
|
|||||||
c.title as course_title,
|
c.title as course_title,
|
||||||
l.transcription_status,
|
l.transcription_status,
|
||||||
l.video_generation_status,
|
l.video_generation_status,
|
||||||
|
l.generation_progress,
|
||||||
l.updated_at
|
l.updated_at
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
JOIN modules m ON l.module_id = m.id
|
JOIN modules m ON l.module_id = m.id
|
||||||
@@ -105,7 +107,9 @@ pub async fn cancel_task(
|
|||||||
// We can't easily kill a running tokio task unless we had a handle map, which we don't.
|
// We can't easily kill a running tokio task unless we had a handle map, which we don't.
|
||||||
// So this is effectively "Dismiss".
|
// So this is effectively "Dismiss".
|
||||||
|
|
||||||
sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1")
|
sqlx::query(
|
||||||
|
"UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1"
|
||||||
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async fn main() {
|
|||||||
for lesson_id in queued_video_lessons {
|
for lesson_id in queued_video_lessons {
|
||||||
tracing::info!("Processing video generation for lesson: {}", lesson_id);
|
tracing::info!("Processing video generation for lesson: {}", lesson_id);
|
||||||
if let Err(e) =
|
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);
|
tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e);
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
@@ -176,6 +176,7 @@ async fn main() {
|
|||||||
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
||||||
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
||||||
.route("/lessons/{id}/generate-image", post(handlers::generate_image))
|
.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/generate", post(handlers::generate_course))
|
||||||
.route("/courses/{id}/export", get(handlers::export_course))
|
.route("/courses/{id}/export", get(handlers::export_course))
|
||||||
.route("/courses/import", post(handlers::import_course))
|
.route("/courses/import", post(handlers::import_course))
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ pub struct Course {
|
|||||||
pub certificate_template: Option<String>,
|
pub certificate_template: Option<String>,
|
||||||
pub price: f64,
|
pub price: f64,
|
||||||
pub currency: String,
|
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 created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RUN cargo build --release -p lms-service
|
|||||||
FROM node:18-alpine AS node-builder
|
FROM node:18-alpine AS node-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/experience/package*.json ./
|
COPY web/experience/package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
COPY web/experience/ .
|
COPY web/experience/ .
|
||||||
ARG NEXT_PUBLIC_LMS_API_URL
|
ARG NEXT_PUBLIC_LMS_API_URL
|
||||||
ENV NEXT_PUBLIC_LMS_API_URL=$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>
|
</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">
|
<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>
|
<p className="text-gray-600 dark:text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from
|
|||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import DiscussionBoard from "@/components/DiscussionBoard";
|
import DiscussionBoard from "@/components/DiscussionBoard";
|
||||||
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
||||||
|
import AboutCourse from "@/components/AboutCourse";
|
||||||
|
|
||||||
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -144,15 +145,48 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />;
|
return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-6 py-20">
|
<div className="max-w-4xl mx-auto px-6 py-20 pb-40">
|
||||||
<div className="mb-16">
|
<div className="mb-12">
|
||||||
<div className="flex items-center gap-2 mb-6 text-blue-600 dark:text-blue-500 font-bold text-xs uppercase tracking-widest">
|
<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</Link>
|
<Link href="/" className="hover:text-gray-900 dark:hover:text-white transition-colors">Catálogo CCB</Link>
|
||||||
<ChevronRight size={14} className="text-gray-600" />
|
<ChevronRight size={14} className="text-blue-300 dark:text-blue-800" />
|
||||||
<span>Detalles del Curso</span>
|
<span className="text-slate-900 dark:text-white">Exploración de Curso</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-5xl font-black tracking-tighter mb-6 text-gray-900 dark:text-white">{courseData.title}</h1>
|
|
||||||
|
{/* --- 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>
|
||||||
|
|
||||||
|
{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">
|
<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."}
|
{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>
|
</p>
|
||||||
@@ -179,7 +213,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{instructors.length > 0 && (
|
{instructors.length > 0 && (
|
||||||
<div className="mb-10 animate-in fade-in slide-in-from-left-4 duration-700">
|
<div className="mb-10">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 mb-4 block">Equipo docente</span>
|
<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">
|
<div className="flex flex-wrap gap-6">
|
||||||
{instructors.map((inst) => (
|
{instructors.map((inst) => (
|
||||||
@@ -197,7 +231,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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 items-center gap-8">
|
||||||
<div className="flex flex-col">
|
<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-[10px] font-black uppercase tracking-widest text-gray-400 dark:text-gray-600 mb-1">Módulos</span>
|
||||||
@@ -242,7 +276,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Recommendations Section */}
|
{/* AI Recommendations Section */}
|
||||||
{(loadingAI || recommendations.length > 0) && (
|
{(loadingAI || recommendations.length > 0) && (
|
||||||
@@ -450,5 +483,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
<DiscussionBoard courseId={params.id} />
|
<DiscussionBoard courseId={params.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</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;
|
lessonId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
url: string;
|
url: string;
|
||||||
media_type: 'video' | 'audio';
|
media_type: 'video' | 'audio' | 'image';
|
||||||
config?: {
|
config?: {
|
||||||
maxPlays?: number;
|
maxPlays?: number;
|
||||||
show_transcript?: boolean;
|
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)
|
// 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) => {
|
const getEmbedUrl = (rawUrl: string) => {
|
||||||
if (rawUrl.includes("youtube.com/watch?v=")) {
|
if (rawUrl.includes("youtube.com/watch?v=")) {
|
||||||
return rawUrl.replace("watch?v=", "embed/");
|
return rawUrl.replace("watch?v=", "embed/");
|
||||||
@@ -144,7 +149,13 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card !p-2 overflow-hidden aspect-video relative group">
|
<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
|
<video
|
||||||
src={getFullUrl(url)}
|
src={getFullUrl(url)}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ export interface Course {
|
|||||||
end_date?: string;
|
end_date?: string;
|
||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
marketing_metadata?: {
|
||||||
|
objectives?: string;
|
||||||
|
requirements?: string;
|
||||||
|
duration?: string;
|
||||||
|
modules_summary?: string;
|
||||||
|
certification_info?: string;
|
||||||
|
};
|
||||||
|
course_image_url?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RUN cargo build --release -p cms-service
|
|||||||
FROM node:18-alpine AS node-builder
|
FROM node:18-alpine AS node-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/studio/package*.json ./
|
COPY web/studio/package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
COPY web/studio/ .
|
COPY web/studio/ .
|
||||||
ARG NEXT_PUBLIC_CMS_API_URL
|
ARG NEXT_PUBLIC_CMS_API_URL
|
||||||
ENV NEXT_PUBLIC_CMS_API_URL=$NEXT_PUBLIC_CMS_API_URL
|
ENV NEXT_PUBLIC_CMS_API_URL=$NEXT_PUBLIC_CMS_API_URL
|
||||||
|
|||||||
@@ -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) {
|
switch (status) {
|
||||||
case 'processing':
|
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':
|
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':
|
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:
|
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>
|
<div className="text-xs text-gray-400 font-mono mt-1">{task.id}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{getStatusBadge(task.transcription_status)}
|
{getStatusBadge(task)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
{format(new Date(task.updated_at), 'MMM d, h:mm a')}
|
{format(new Date(task.updated_at), 'MMM d, h:mm a')}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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 {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -40,6 +40,7 @@ import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
|
|||||||
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
|
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
|
||||||
import LibraryPanel from "@/components/LibraryPanel";
|
import LibraryPanel from "@/components/LibraryPanel";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
import MediaPlayer from "@/components/MediaPlayer";
|
||||||
|
|
||||||
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
@@ -1152,6 +1153,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
</div>
|
</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 && (
|
{editMode && (
|
||||||
<div className="pt-20 border-t border-slate-100 dark:border-white/5">
|
<div className="pt-20 border-t border-slate-100 dark:border-white/5">
|
||||||
<div className="flex flex-col items-center gap-12">
|
<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 =
|
type TabKey =
|
||||||
| "outline"
|
| "outline"
|
||||||
|
| "marketing"
|
||||||
| "grading"
|
| "grading"
|
||||||
| "rubrics"
|
| "rubrics"
|
||||||
| "calendar"
|
| "calendar"
|
||||||
@@ -73,6 +74,7 @@ export default function CourseEditorLayout({
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
tabs: [
|
tabs: [
|
||||||
{ key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` },
|
{ 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: "files", label: "Archivos", icon: Folder, href: `/courses/${id}/files` },
|
||||||
{ key: "sessions", label: "Sesiones en Vivo", icon: Video, href: `/courses/${id}/sessions` },
|
{ key: "sessions", label: "Sesiones en Vivo", icon: Video, href: `/courses/${id}/sessions` },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||||
{type === "video" ? (
|
{type === "video" ? (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface MediaBlockProps {
|
|||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: 'video' | 'audio';
|
type: 'video' | 'audio' | 'image';
|
||||||
config: {
|
config: {
|
||||||
maxPlays?: number;
|
maxPlays?: number;
|
||||||
currentPlays?: number;
|
currentPlays?: number;
|
||||||
@@ -44,7 +44,9 @@ interface MediaBlockProps {
|
|||||||
|
|
||||||
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription, isGraded }: MediaBlockProps) {
|
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription, isGraded }: MediaBlockProps) {
|
||||||
const [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
|
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 maxPlays = config.maxPlays || 0;
|
||||||
const isLocked = maxPlays > 0 && localPlays >= maxPlays;
|
const isLocked = maxPlays > 0 && localPlays >= maxPlays;
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ export const LMS_API_BASE_URL = getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LM
|
|||||||
export const getImageUrl = (path?: string) => {
|
export const getImageUrl = (path?: string) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
if (path.startsWith('http')) return path;
|
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
|
// 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}`;
|
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||||
return `${API_BASE_URL}${finalPath}`;
|
return `${API_BASE_URL}${finalPath}`;
|
||||||
};
|
};
|
||||||
@@ -34,6 +37,17 @@ export interface Course {
|
|||||||
certificate_template?: string;
|
certificate_template?: string;
|
||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
modules?: Module[];
|
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) }),
|
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' }),
|
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) }),
|
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 }) }),
|
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' }),
|
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
|
||||||
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
|
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
|
||||||
@@ -961,5 +976,6 @@ export interface BackgroundTask {
|
|||||||
course_title?: string;
|
course_title?: string;
|
||||||
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
||||||
video_generation_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
video_generation_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
||||||
|
generation_progress?: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user