actualizaciones

This commit is contained in:
2026-03-03 17:44:24 -03:00
parent 15f2649777
commit 4458decd22
14 changed files with 339 additions and 15 deletions
+3 -1
View File
@@ -27,7 +27,7 @@ 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).
- **Auto Transcription**: Integración con Whisper para generación automática de transcripciones y evaluación precisa de voz. - **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*.
- **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,6 +78,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.
- **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.
@@ -654,6 +655,7 @@ OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo f
- **Offline Sync**: Capacidad de descargar lecciones y sincronizar progreso al recuperar conexión. - **Offline Sync**: Capacidad de descargar lecciones y sincronizar progreso al recuperar conexión.
### 🧠 Inteligencia Artificial Avanzada ### 🧠 Inteligencia Artificial Avanzada
- **AI Video Generation (v1)**: ✅ Generación de clips a partir de prompts y guiones de lecciones.
- **AI Proctoring**: Monitoreo basado en visión artificial para exámenes de alta integridad, 100% privado y local. - **AI Proctoring**: Monitoreo basado en visión artificial para exámenes de alta integridad, 100% privado y local.
- **Multimodal Tutoring**: El tutor de IA podrá analizar imágenes y videos subidos por el alumno para dar feedback. - **Multimodal Tutoring**: El tutor de IA podrá analizar imágenes y videos subidos por el alumno para dar feedback.
- **Automated Grading for Open Questions**: Evaluación masiva de ensayos y respuestas abiertas con rúbricas personalizadas. - **Automated Grading for Open Questions**: Evaluación masiva de ensayos y respuestas abiertas con rúbricas personalizadas.
+4 -1
View File
@@ -117,11 +117,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
REMOTE_VIDEO_URL=${REMOTE_VIDEO_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"
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"
@@ -213,7 +216,7 @@ if [ "$ADMIN_EXISTS" != "t" ]; then
read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS
ADMIN_PASS=${ADMIN_PASS:-password123} ADMIN_PASS=${ADMIN_PASS:-password123}
echo "" echo ""
ORG_NAME="Organización por Defecto" ORG_NAME="Default Organization"
fi fi
echo "" echo ""
@@ -12,7 +12,7 @@ DECLARE
v_org_id UUID; v_org_id UUID;
BEGIN BEGIN
-- Find or create organization -- Find or create organization
IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' THEN IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' OR p_org_name = 'Organización por Defecto' THEN
v_org_id := '00000000-0000-0000-0000-000000000001'; v_org_id := '00000000-0000-0000-0000-000000000001';
ELSE ELSE
INSERT INTO organizations (name) INSERT INTO organizations (name)
@@ -0,0 +1,3 @@
-- Add video_generation_status and video_generation_error to lessons table
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS video_generation_status VARCHAR(20) DEFAULT 'idle';
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS video_generation_error TEXT;
@@ -0,0 +1,9 @@
fastapi
uvicorn
torch
diffusers
transformers
accelerate
pillow
imageio-ffmpeg
pydantic
@@ -0,0 +1,88 @@
import os
import time
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import torch
from diffusers import StableDiffusionPipeline
from PIL import Image
import uuid
app = FastAPI()
# Configuration
MODEL_ID = "runwayml/stable-diffusion-v1-5"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_DIR = os.path.join(BASE_DIR, "outputs")
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Global variables for the model
pipe = None
def load_model():
global pipe
print("Loading Stable Diffusion model on CPU...")
pipe = StableDiffusionPipeline.from_pretrained(
MODEL_ID,
torch_dtype=torch.float32,
)
pipe.to("cpu")
# pipe.enable_model_cpu_offload()
pipe.enable_attention_slicing()
print("Model loaded successfully.")
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Model loading is heavy, we'll do it on first request to avoid timeout at startup
yield
app = FastAPI(lifespan=lifespan)
# Serve generated images
from fastapi.staticfiles import StaticFiles
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
class ImageRequest(BaseModel):
prompt: str
lesson_id: str
@app.post("/generate")
async def generate_image(request: ImageRequest):
global pipe
if pipe is None:
load_model()
try:
print(f"Generating image for prompt: {request.prompt}")
generator = torch.manual_seed(42)
# Using a small number of steps for speed since it's on CPU
image = pipe(
request.prompt,
num_inference_steps=20,
generator=generator
).images[0]
image_filename = f"image_{request.lesson_id}_{uuid.uuid4().hex[:8]}.png"
image_path = os.path.join(OUTPUT_DIR, image_filename)
image.save(image_path)
# Return the absolute URL pointing to t-800 so the frontend can find it
hostname = os.getenv("BRIDGE_HOSTNAME", "localhost")
full_url = f"http://{hostname}:8080/outputs/{image_filename}"
return {"status": "completed", "url": full_url}
except Exception as e:
print(f"Generation error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": pipe is not None}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)
+119
View File
@@ -1283,6 +1283,125 @@ pub async fn generate_quiz(
Ok(Json(quiz_blocks)) Ok(Json(quiz_blocks))
} }
#[derive(Deserialize)]
pub struct VideoAIRequest {
pub prompt: Option<String>,
}
pub async fn generate_image(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<VideoAIRequest>,
) -> Result<Json<Lesson>, StatusCode> {
if let Some(prompt) = &payload.prompt {
tracing::info!("Received prompt for video generation: {}", prompt);
}
// 1. Fetch lesson
let _lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons 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_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons SET video_generation_status = 'queued', video_generation_error = NULL WHERE id = $1 RETURNING *",
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database update failed (video queued): {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
log_action(
&pool,
org_ctx.id,
claims.sub,
"VIDEO_GENERATION_QUEUED",
"Lesson",
id,
json!({ "status": "queued" }),
)
.await;
// 3. Spawn background task
let pool_clone = pool.clone();
let prompt_to_task = payload.prompt.clone();
tokio::spawn(async move {
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task).await {
tracing::error!("Image generation task failed for lesson {}: {}", id, e);
}
});
Ok(Json(updated_lesson))
}
pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_prompt: Option<String>) -> Result<(), String> {
// 1. Set status to processing
sqlx::query("UPDATE lessons SET video_generation_status = 'processing' WHERE id = $1")
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Update to processing failed: {}", e))?;
// 2. Call Local Video Bridge (Python)
let client = reqwest::Client::new();
let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let bridge_url = format!("{}/generate", bridge_base_url);
// Fallback logic for prompt: Custom Prompt > Title
let final_prompt = match custom_prompt {
Some(p) if !p.is_empty() => p,
_ => {
sqlx::query_scalar("SELECT title FROM lessons WHERE id = $1")
.bind(lesson_id)
.fetch_one(&pool)
.await
.map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?
}
};
let response = client.post(bridge_url)
.json(&serde_json::json!({
"prompt": final_prompt,
"lesson_id": lesson_id.to_string()
}))
.send()
.await
.map_err(|e| format!("Failed to call video bridge: {}", e))?;
if !response.status().is_success() {
let err_text = response.text().await.unwrap_or_default();
return Err(format!("Video bridge error: {}", err_text));
}
let result: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse video bridge response: {}", e))?;
let content_url = result["url"].as_str()
.ok_or_else(|| "Video bridge response missing URL".to_string())?;
// 3. Complete task
sqlx::query(
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2"
)
.bind(content_url)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Update to completed failed: {}", e))?;
Ok(())
}
pub async fn get_lesson( pub async fn get_lesson(
Org(org_ctx): Org, Org(org_ctx): Org,
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -14,6 +14,7 @@ pub struct BackgroundTask {
pub title: String, pub title: String,
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 updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
} }
@@ -34,11 +35,13 @@ pub async fn get_background_tasks(
l.title, l.title,
c.title as course_title, c.title as course_title,
l.transcription_status, l.transcription_status,
l.video_generation_status,
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
JOIN courses c ON m.course_id = c.id JOIN courses c ON m.course_id = c.id
WHERE l.transcription_status IN ('queued', 'processing', 'failed') WHERE l.transcription_status IN ('queued', 'processing', 'failed')
OR l.video_generation_status IN ('queued', 'processing', 'failed')
ORDER BY l.updated_at DESC ORDER BY l.updated_at DESC
"#; "#;
+31
View File
@@ -74,6 +74,36 @@ async fn main() {
} }
} }
// Check for queued video generations
let queued_video_lessons: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
"SELECT id FROM lessons WHERE video_generation_status = 'queued' LIMIT 5",
)
.fetch_all(&worker_pool)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to fetch queued video lessons: {}", e);
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
};
for lesson_id in queued_video_lessons {
tracing::info!("Processing video generation for lesson: {}", lesson_id);
if let Err(e) =
handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None).await
{
tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e);
let _ = sqlx::query(
"UPDATE lessons SET video_generation_status = 'failed' WHERE id = $1",
)
.bind(lesson_id)
.execute(&worker_pool)
.await;
}
}
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
} }
}); });
@@ -145,6 +175,7 @@ async fn main() {
.route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt))
.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("/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))
+2
View File
@@ -50,6 +50,8 @@ pub struct Lesson {
pub due_date: Option<DateTime<Utc>>, pub due_date: Option<DateTime<Utc>>,
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc. pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
pub transcription_status: Option<String>, pub transcription_status: Option<String>,
pub video_generation_status: Option<String>,
pub video_generation_error: Option<String>,
pub is_previewable: bool, pub is_previewable: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -21,7 +21,8 @@ import {
Brain, Brain,
Library, Library,
BookMarked, BookMarked,
ArrowLeft ArrowLeft,
ImageIcon
} from 'lucide-react'; } from 'lucide-react';
import DescriptionBlock from "@/components/blocks/DescriptionBlock"; import DescriptionBlock from "@/components/blocks/DescriptionBlock";
import MediaBlock from "@/components/blocks/MediaBlock"; import MediaBlock from "@/components/blocks/MediaBlock";
@@ -78,6 +79,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false); const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
const [aiQuizContext, setAiQuizContext] = useState(""); const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice"); const [aiQuizType, setAiQuizType] = useState("multiple-choice");
const [aiImagePrompt, setAiImagePrompt] = useState("");
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
@@ -86,7 +89,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
if (lesson && (lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing')) { if (lesson && (
lesson.video_generation_status === 'queued' ||
lesson.video_generation_status === 'processing'
)) {
interval = setInterval(async () => { interval = setInterval(async () => {
try { try {
const updated = await cmsApi.getLesson(params.lessonId); const updated = await cmsApi.getLesson(params.lessonId);
@@ -362,6 +368,32 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
} }
}; };
const handleGenerateImage = async () => {
if (!lesson) return;
setIsGeneratingImage(true);
try {
// Option 2: Use lesson content (blocks) as script if no prompt
const scriptFromBlocks = blocks
.map(b => b.content || b.description || b.title || "")
.filter(txt => txt.trim().length > 0)
.join(". ");
// Option 3: Prioritize manual prompt, then script
const finalPrompt = aiImagePrompt.trim() || scriptFromBlocks;
const updated = await cmsApi.generateImage(lesson.id, {
prompt: finalPrompt
});
setLesson(updated);
setAiImagePrompt("");
alert("Generación de imagen iniciada con el prompt/guion detectado.");
} catch (err: any) {
alert(err.message || "Failed to initiate image generation.");
} finally {
setIsGeneratingImage(false);
}
};
if (loading) return <div className="py-20 text-center text-gray-500 animate-pulse font-medium">Initializing Activity Builder...</div>; if (loading) return <div className="py-20 text-center text-gray-500 animate-pulse font-medium">Initializing Activity Builder...</div>;
if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>; if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>;
@@ -872,6 +904,35 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<div className="font-black text-xl tracking-tight">{isGeneratingQuiz ? 'Building Quiz...' : 'Generate New Test'}</div> <div className="font-black text-xl tracking-tight">{isGeneratingQuiz ? 'Building Quiz...' : 'Generate New Test'}</div>
</div> </div>
</button> </button>
<div className="col-span-full space-y-3 mt-4">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<ImageIcon size={14} className="text-amber-500" />
Custom Image Prompt (Optional)
</label>
<textarea
value={aiImagePrompt}
onChange={(e) => setAiImagePrompt(e.target.value)}
placeholder="Describe what you want to see in the image, or leave blank to use the lesson script..."
className="w-full bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-2xl p-4 text-sm focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 outline-none transition-all min-h-[100px] text-slate-700 dark:text-gray-300"
/>
</div>
<button
onClick={handleGenerateImage}
disabled={isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing'}
className={`group/btn p-8 border rounded-[2rem] transition-all text-left flex flex-col gap-4 shadow-sm relative overflow-hidden ${isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing' ? 'bg-amber-50 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/50 text-amber-600 dark:text-amber-300 animate-pulse' : 'bg-white dark:bg-white/5 border-slate-100 dark:border-white/10 text-slate-400 dark:text-amber-400 hover:border-amber-500/50 hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-500/10'}`}
>
<div className="w-12 h-12 rounded-2xl bg-amber-100 dark:bg-amber-500/20 flex items-center justify-center text-amber-600 dark:text-amber-400 shadow-sm group-hover/btn:scale-110 transition-transform">
{(isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing') ? '⏳' : <ImageIcon />}
</div>
<div className="space-y-1">
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-80">Generative AI</div>
<div className="font-black text-xl tracking-tight">
{(isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing') ? 'Generating Image...' : 'Generate AI Image'}
</div>
</div>
</button>
</div> </div>
</div> </div>
) )
+6 -6
View File
@@ -281,13 +281,13 @@ export default function StudioDashboard() {
title="AI Course Wizard" title="AI Course Wizard"
> >
<form onSubmit={handleAIGenerate} className="space-y-6"> <form onSubmit={handleAIGenerate} className="space-y-6">
<div className="p-4 rounded-xl bg-indigo-500/5 border border-indigo-500/10 mb-2"> <div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 mb-2">
<p className="text-xs text-indigo-300 leading-relaxed font-medium"> <p className="text-sm text-blue-800 dark:text-blue-300 leading-relaxed font-medium">
Describe the course topic and target audience. Our AI will structure the modules and lessons for you in seconds. Describe the course topic and target audience. Our AI will structure the modules and lessons for you in seconds.
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2"> <label className="block text-sm font-bold text-slate-700 dark:text-gray-300 mb-2">
Course Topic or Description Course Topic or Description
</label> </label>
<textarea <textarea
@@ -297,7 +297,7 @@ export default function StudioDashboard() {
value={aiPrompt} value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)} onChange={(e) => setAiPrompt(e.target.value)}
placeholder="e.g. A comprehensive guide to building distributed systems with Rust and Axum for intermediate developers." placeholder="e.g. A comprehensive guide to building distributed systems with Rust and Axum for intermediate developers."
className="w-full bg-black/5 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all text-gray-900 dark:text-white resize-none" className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white resize-none placeholder:text-slate-400 dark:placeholder:text-gray-600"
disabled={isGenerating} disabled={isGenerating}
/> />
</div> </div>
@@ -306,14 +306,14 @@ export default function StudioDashboard() {
type="button" type="button"
onClick={() => setIsAIModalOpen(false)} onClick={() => setIsAIModalOpen(false)}
disabled={isGenerating} disabled={isGenerating}
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium" className="flex-1 px-4 py-3 bg-slate-100 dark:bg-white/5 hover:bg-slate-200 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl transition-all text-sm font-bold text-slate-700 dark:text-gray-300"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={isGenerating} disabled={isGenerating}
className="flex-[2] px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-all shadow-lg shadow-indigo-500/20 font-bold text-sm flex items-center justify-center gap-2" className="flex-[2] px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/25 font-bold text-sm flex items-center justify-center gap-2 active:scale-[0.98]"
> >
{isGenerating ? ( {isGenerating ? (
<> <>
+4 -4
View File
@@ -32,21 +32,21 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps)
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-md animate-in fade-in duration-300">
<div <div
ref={modalRef} ref={modalRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-title" aria-labelledby="modal-title"
className="w-full max-w-md glass-card bg-[#1a1d23] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-200" className="w-full max-w-md glass-card bg-white/90 dark:bg-slate-900/90 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-200 flex flex-col"
> >
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 id="modal-title" className="text-xl font-bold bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent italic"> <h2 id="modal-title" className="text-xl font-bold bg-gradient-to-r from-slate-900 to-slate-600 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
{title} {title}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-white/5 rounded-full transition-colors text-gray-400 hover:text-white" className="p-2 hover:bg-slate-100 dark:hover:bg-white/5 rounded-full transition-colors text-slate-400 dark:text-gray-400 hover:text-slate-900 dark:hover:text-white"
> >
<X size={20} aria-hidden="true" /> <X size={20} aria-hidden="true" />
</button> </button>
+3
View File
@@ -112,6 +112,7 @@ export interface Lesson {
cues?: { start: number; end: number; text: string }[]; cues?: { start: number; end: number; text: string }[];
} | null; } | null;
transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed'; transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
video_generation_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
is_previewable: boolean; is_previewable: boolean;
created_at: string; created_at: string;
} }
@@ -631,6 +632,7 @@ 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) }),
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' }),
@@ -958,5 +960,6 @@ export interface BackgroundTask {
title: string; title: string;
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';
updated_at: string; updated_at: string;
} }