feat: add organization exercise settings management
- Created a new SQL migration to define the organization_exercise_settings table with relevant fields and an index. - Implemented handlers for loading and updating organization exercise settings in Rust, including default values and upsert functionality. - Developed a React component for managing exercise feature settings, allowing toggling of features and saving updates to the backend.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS organization_exercise_settings (
|
||||||
|
organization_id UUID PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
audio_response_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
hotspot_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
memory_match_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
peer_review_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
role_playing_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
mermaid_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
code_lab_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_exercise_settings_updated_at
|
||||||
|
ON organization_exercise_settings (updated_at DESC);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::exporter;
|
use crate::exporter;
|
||||||
|
use crate::handlers_exercise_settings::load_organization_exercise_settings;
|
||||||
use crate::webhooks::WebhookService;
|
use crate::webhooks::WebhookService;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -6,6 +7,12 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_config::meta::region::RegionProviderChain;
|
||||||
|
use aws_sdk_s3::{
|
||||||
|
Client as S3Client,
|
||||||
|
config::{Credentials, Region},
|
||||||
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
pub use common::auth::Claims;
|
pub use common::auth::Claims;
|
||||||
@@ -30,6 +37,21 @@ use openidconnect::{
|
|||||||
RedirectUrl, Scope, TokenResponse,
|
RedirectUrl, Scope, TokenResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async fn is_org_exercise_enabled(
|
||||||
|
pool: &PgPool,
|
||||||
|
organization_id: Uuid,
|
||||||
|
feature: &str,
|
||||||
|
) -> Result<bool, StatusCode> {
|
||||||
|
let settings = load_organization_exercise_settings(pool, organization_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to load exercise settings for org {}: {}", organization_id, e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(settings.is_enabled(feature))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SSOCallbackParams {
|
pub struct SSOCallbackParams {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
@@ -63,6 +85,126 @@ fn count_tokens(text: &str) -> i32 {
|
|||||||
((char_count as f64) / 4.0).ceil() as i32
|
((char_count as f64) / 4.0).ceil() as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct HotspotS3Settings {
|
||||||
|
region: String,
|
||||||
|
endpoint: Option<String>,
|
||||||
|
force_path_style: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_hotspot_s3_settings() -> Option<HotspotS3Settings> {
|
||||||
|
let enabled = env::var("ASSETS_STORAGE")
|
||||||
|
.unwrap_or_else(|_| "local".to_string())
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if enabled != "s3" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let region = env::var("S3_REGION").unwrap_or_else(|_| "us-east-2".to_string());
|
||||||
|
let endpoint = env::var("S3_ENDPOINT").ok().filter(|v| !v.trim().is_empty());
|
||||||
|
let force_path_style = env::var("S3_FORCE_PATH_STYLE")
|
||||||
|
.map(|v| {
|
||||||
|
let lower = v.to_lowercase();
|
||||||
|
lower == "1" || lower == "true" || lower == "yes"
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
Some(HotspotS3Settings {
|
||||||
|
region,
|
||||||
|
endpoint,
|
||||||
|
force_path_style,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_hotspot_s3_client(settings: &HotspotS3Settings) -> Result<S3Client, StatusCode> {
|
||||||
|
let region_provider = RegionProviderChain::first_try(Some(Region::new(settings.region.clone())))
|
||||||
|
.or_default_provider();
|
||||||
|
|
||||||
|
let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region_provider);
|
||||||
|
|
||||||
|
let access_key = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||||
|
let secret_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||||
|
if let (Some(ak), Some(sk)) = (access_key, secret_key) {
|
||||||
|
let creds = Credentials::new(ak, sk, None, None, "env");
|
||||||
|
loader = loader.credentials_provider(creds);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared_config = loader.load().await;
|
||||||
|
let mut s3_builder = aws_sdk_s3::config::Builder::from(&shared_config);
|
||||||
|
if let Some(endpoint) = &settings.endpoint {
|
||||||
|
s3_builder = s3_builder.endpoint_url(endpoint);
|
||||||
|
}
|
||||||
|
if settings.force_path_style {
|
||||||
|
s3_builder = s3_builder.force_path_style(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(S3Client::from_conf(s3_builder.build()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hotspot_s3_proxy_path(path: &str) -> Option<(String, String)> {
|
||||||
|
let normalized = path.trim_start_matches('/');
|
||||||
|
let remainder = normalized
|
||||||
|
.strip_prefix("cms-api/api/assets/s3-proxy/")
|
||||||
|
.or_else(|| normalized.strip_prefix("api/assets/s3-proxy/"))?;
|
||||||
|
|
||||||
|
let mut parts = remainder.splitn(2, '/');
|
||||||
|
let bucket = parts.next()?.trim();
|
||||||
|
let key = parts.next()?.trim();
|
||||||
|
if bucket.is_empty() || key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((bucket.to_string(), key.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hotspot_s3_uri(path: &str) -> Option<(String, String)> {
|
||||||
|
let remainder = path.strip_prefix("s3://")?;
|
||||||
|
let mut parts = remainder.splitn(2, '/');
|
||||||
|
let bucket = parts.next()?.trim();
|
||||||
|
let key = parts.next()?.trim();
|
||||||
|
if bucket.is_empty() || key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((bucket.to_string(), key.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_hotspot_s3_proxy_bytes(path: &str) -> Result<Option<(Vec<u8>, String)>, StatusCode> {
|
||||||
|
let s3_ref = parse_hotspot_s3_proxy_path(path).or_else(|| parse_hotspot_s3_uri(path));
|
||||||
|
let Some((bucket, key)) = s3_ref else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(settings) = get_hotspot_s3_settings() else {
|
||||||
|
tracing::warn!("Hotspot received S3 proxy path but ASSETS_STORAGE is not configured for S3: {}", path);
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = build_hotspot_s3_client(&settings).await?;
|
||||||
|
let output = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(&key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to read hotspot image from S3 {}/{}: {}", bucket, key, e);
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let bytes = output.body.collect().await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to collect hotspot image body from S3 {}/{}: {}", bucket, key, e);
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mime = mime_guess::from_path(&key)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(Some((bytes.into_bytes().to_vec(), mime)))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn publish_course(
|
pub async fn publish_course(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
@@ -1879,19 +2021,29 @@ pub async fn reorder_lessons(
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct GenerateMermaidPayload {
|
pub struct GenerateMermaidPayload {
|
||||||
|
#[allow(dead_code)]
|
||||||
pub prompt_hint: Option<String>,
|
pub prompt_hint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_mermaid_diagram(
|
pub async fn generate_mermaid_diagram(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
_claims: Claims,
|
claims: Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(lesson_id): Path<Uuid>,
|
Path(lesson_id): Path<Uuid>,
|
||||||
Json(payload): Json<GenerateMermaidPayload>,
|
Json(payload): Json<GenerateMermaidPayload>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
if !is_org_exercise_enabled(&pool, org_ctx.id, "mermaid")
|
||||||
|
.await
|
||||||
|
.map_err(|status| (status, "No se pudo validar la configuración de Mermaid".to_string()))?
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"La generación de diagramas Mermaid está desactivada para esta organización".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Generating Mermaid Diagram for lesson_id={}", lesson_id);
|
tracing::info!("Generating Mermaid Diagram for lesson_id={}", lesson_id);
|
||||||
|
|
||||||
// Fetch lesson for context
|
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(lesson_id)
|
.bind(lesson_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -1972,19 +2124,17 @@ pub async fn generate_mermaid_diagram(
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Clean any accidental markdown backticks the LLM might have still inserted
|
|
||||||
let cleaned_response = ai_response
|
let cleaned_response = ai_response
|
||||||
.strip_prefix("```mermaid\n").unwrap_or(ai_response)
|
.strip_prefix("```mermaid\n").unwrap_or(ai_response)
|
||||||
.strip_prefix("```\n").unwrap_or(ai_response)
|
.strip_prefix("```\n").unwrap_or(ai_response)
|
||||||
.strip_suffix("```").unwrap_or(ai_response).trim();
|
.strip_suffix("```").unwrap_or(ai_response).trim();
|
||||||
|
|
||||||
// Calculate and log token usage
|
|
||||||
let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el código Mermaid directamente.");
|
let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el código Mermaid directamente.");
|
||||||
let output_tokens = count_tokens(cleaned_response);
|
let output_tokens = count_tokens(cleaned_response);
|
||||||
let total_tokens = input_tokens + output_tokens;
|
let total_tokens = input_tokens + output_tokens;
|
||||||
|
|
||||||
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||||
.bind(_claims.sub)
|
.bind(claims.sub)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(total_tokens)
|
.bind(total_tokens)
|
||||||
.bind(input_tokens)
|
.bind(input_tokens)
|
||||||
@@ -1996,8 +2146,8 @@ pub async fn generate_mermaid_diagram(
|
|||||||
"lesson_id": lesson_id,
|
"lesson_id": lesson_id,
|
||||||
"hint": payload.prompt_hint,
|
"hint": payload.prompt_hint,
|
||||||
}))
|
}))
|
||||||
.bind(&system_prompt) // prompt
|
.bind(&system_prompt)
|
||||||
.bind(cleaned_response) // response
|
.bind(cleaned_response)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -2019,6 +2169,16 @@ pub async fn generate_code_lab(
|
|||||||
Path(lesson_id): Path<Uuid>,
|
Path(lesson_id): Path<Uuid>,
|
||||||
Json(payload): Json<GenerateCodeLabPayload>,
|
Json(payload): Json<GenerateCodeLabPayload>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
if !is_org_exercise_enabled(&pool, org_ctx.id, "code-lab")
|
||||||
|
.await
|
||||||
|
.map_err(|status| (status, "No se pudo validar la configuración de Code Lab".to_string()))?
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Code Lab está desactivado para esta organización".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Generating Code Lab for lesson_id={}", lesson_id);
|
tracing::info!("Generating Code Lab for lesson_id={}", lesson_id);
|
||||||
|
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
@@ -2159,28 +2319,128 @@ pub async fn generate_hotspots(
|
|||||||
Path(lesson_id): Path<Uuid>,
|
Path(lesson_id): Path<Uuid>,
|
||||||
Json(payload): Json<GenerateHotspotsPayload>,
|
Json(payload): Json<GenerateHotspotsPayload>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
if !is_org_exercise_enabled(&pool, org_ctx.id, "hotspot").await? {
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
// Check token limit before proceeding (estimate 2000 tokens for hotspots)
|
// Check token limit before proceeding (estimate 2000 tokens for hotspots)
|
||||||
if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2000).await {
|
if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2000).await {
|
||||||
return Err(StatusCode::TOO_MANY_REQUESTS);
|
return Err(StatusCode::TOO_MANY_REQUESTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Resolve image path
|
// 1. Resolve image path
|
||||||
// imageUrl in frontend is like "/assets/filename.ext"
|
// Accept common formats used by Studio:
|
||||||
// We need to map it to "uploads/filename.ext"
|
// - /assets/<key>
|
||||||
let filename = payload.image_url.split('/').last().unwrap_or_default();
|
// - /uploads/<key>
|
||||||
if filename.is_empty() {
|
// - assets/<key>
|
||||||
|
// - uploads/<key>
|
||||||
|
// - absolute URLs (we use only their path component)
|
||||||
|
let raw_input = payload.image_url.trim();
|
||||||
|
if raw_input.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_absolute_url = raw_input.starts_with("http://") || raw_input.starts_with("https://");
|
||||||
|
let mut path_only = if is_absolute_url {
|
||||||
|
match reqwest::Url::parse(raw_input) {
|
||||||
|
Ok(url) => url.path().to_string(),
|
||||||
|
Err(_) => raw_input.to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw_input.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((without_query, _)) = path_only.split_once('?') {
|
||||||
|
path_only = without_query.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut storage_path = if let Some(rest) = path_only.strip_prefix("/assets/") {
|
||||||
|
format!("uploads/{}", rest)
|
||||||
|
} else if let Some(rest) = path_only.strip_prefix("assets/") {
|
||||||
|
format!("uploads/{}", rest)
|
||||||
|
} else if let Some(rest) = path_only.strip_prefix("/uploads/") {
|
||||||
|
format!("uploads/{}", rest)
|
||||||
|
} else if path_only.starts_with("uploads/") {
|
||||||
|
path_only.clone()
|
||||||
|
} else {
|
||||||
|
let filename = path_only.split('/').last().unwrap_or_default();
|
||||||
|
if filename.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
format!("uploads/{}", filename)
|
||||||
|
};
|
||||||
|
|
||||||
|
storage_path = storage_path.replace('\\', "/");
|
||||||
|
if storage_path.contains("..") {
|
||||||
|
tracing::warn!("Invalid hotspot image path traversal attempt: {}", storage_path);
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
let storage_path = format!("uploads/{}", filename);
|
|
||||||
|
|
||||||
// 2. Read and encode image
|
// 2. Read and encode image
|
||||||
let image_data = tokio::fs::read(&storage_path).await.map_err(|e| {
|
// Prefer direct HTTP fetch for absolute URLs (e.g. /api/assets/s3-proxy),
|
||||||
tracing::error!("Failed to read image at {}: {}", storage_path, e);
|
// and fallback to local disk resolution for legacy /assets/* and uploads/* paths.
|
||||||
StatusCode::NOT_FOUND
|
let (image_data, mime_type) = if is_absolute_url {
|
||||||
})?;
|
match reqwest::get(raw_input).await {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
mime_guess::from_path(&path_only)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let bytes = response.bytes().await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to read hotspot image bytes from {}: {}", raw_input, e);
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
})?;
|
||||||
|
|
||||||
|
(bytes.to_vec(), content_type)
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Hotspot image URL {} returned non-success status {}. Falling back to local path {}",
|
||||||
|
raw_input,
|
||||||
|
response.status(),
|
||||||
|
storage_path
|
||||||
|
);
|
||||||
|
let bytes = tokio::fs::read(&storage_path).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to read image at {}: {}", storage_path, e);
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
})?;
|
||||||
|
let mime = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string();
|
||||||
|
(bytes, mime)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Hotspot image URL fetch failed for {}: {}. Falling back to local path {}",
|
||||||
|
raw_input,
|
||||||
|
err,
|
||||||
|
storage_path
|
||||||
|
);
|
||||||
|
let bytes = tokio::fs::read(&storage_path).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to read image at {}: {}", storage_path, e);
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
})?;
|
||||||
|
let mime = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string();
|
||||||
|
(bytes, mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((bytes, mime)) = read_hotspot_s3_proxy_bytes(&path_only).await? {
|
||||||
|
(bytes, mime)
|
||||||
|
} else {
|
||||||
|
let bytes = tokio::fs::read(&storage_path).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to read image at {}: {}", storage_path, e);
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
})?;
|
||||||
|
let mime = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string();
|
||||||
|
(bytes, mime)
|
||||||
|
};
|
||||||
|
|
||||||
let base64_image = general_purpose::STANDARD.encode(image_data);
|
let base64_image = general_purpose::STANDARD.encode(image_data);
|
||||||
let mime_type = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string();
|
|
||||||
let image_url_data = format!("data:{};base64,{}", mime_type, base64_image);
|
let image_url_data = format!("data:{};base64,{}", mime_type, base64_image);
|
||||||
|
|
||||||
// 3. Fetch lesson context (optional but helpful for AI)
|
// 3. Fetch lesson context (optional but helpful for AI)
|
||||||
@@ -2372,6 +2632,10 @@ pub async fn generate_role_play(
|
|||||||
Path(lesson_id): Path<Uuid>,
|
Path(lesson_id): Path<Uuid>,
|
||||||
Json(payload): Json<GenerateRolePlayPayload>,
|
Json(payload): Json<GenerateRolePlayPayload>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
if !is_org_exercise_enabled(&pool, org_ctx.id, "role-playing").await? {
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
// Check token limit before proceeding (estimate 2500 tokens for role-play)
|
// Check token limit before proceeding (estimate 2500 tokens for role-play)
|
||||||
if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2500).await {
|
if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2500).await {
|
||||||
return Err(StatusCode::TOO_MANY_REQUESTS);
|
return Err(StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::handlers::{log_action, Org};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||||
|
pub struct OrganizationExerciseSettings {
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub audio_response_enabled: bool,
|
||||||
|
pub hotspot_enabled: bool,
|
||||||
|
pub memory_match_enabled: bool,
|
||||||
|
pub peer_review_enabled: bool,
|
||||||
|
pub role_playing_enabled: bool,
|
||||||
|
pub mermaid_enabled: bool,
|
||||||
|
pub code_lab_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrganizationExerciseSettings {
|
||||||
|
pub fn defaults(organization_id: Uuid) -> Self {
|
||||||
|
Self {
|
||||||
|
organization_id,
|
||||||
|
audio_response_enabled: true,
|
||||||
|
hotspot_enabled: true,
|
||||||
|
memory_match_enabled: true,
|
||||||
|
peer_review_enabled: true,
|
||||||
|
role_playing_enabled: true,
|
||||||
|
mermaid_enabled: false,
|
||||||
|
code_lab_enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self, feature: &str) -> bool {
|
||||||
|
match feature {
|
||||||
|
"audio-response" => self.audio_response_enabled,
|
||||||
|
"hotspot" => self.hotspot_enabled,
|
||||||
|
"memory-match" => self.memory_match_enabled,
|
||||||
|
"peer-review" => self.peer_review_enabled,
|
||||||
|
"role-playing" => self.role_playing_enabled,
|
||||||
|
"mermaid" => self.mermaid_enabled,
|
||||||
|
"code-lab" => self.code_lab_enabled,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateOrganizationExerciseSettingsPayload {
|
||||||
|
pub audio_response_enabled: bool,
|
||||||
|
pub hotspot_enabled: bool,
|
||||||
|
pub memory_match_enabled: bool,
|
||||||
|
pub peer_review_enabled: bool,
|
||||||
|
pub role_playing_enabled: bool,
|
||||||
|
pub mermaid_enabled: bool,
|
||||||
|
pub code_lab_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_organization_exercise_settings(
|
||||||
|
pool: &PgPool,
|
||||||
|
organization_id: Uuid,
|
||||||
|
) -> Result<OrganizationExerciseSettings, sqlx::Error> {
|
||||||
|
let settings = sqlx::query_as::<_, OrganizationExerciseSettings>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
organization_id,
|
||||||
|
audio_response_enabled,
|
||||||
|
hotspot_enabled,
|
||||||
|
memory_match_enabled,
|
||||||
|
peer_review_enabled,
|
||||||
|
role_playing_enabled,
|
||||||
|
mermaid_enabled,
|
||||||
|
code_lab_enabled
|
||||||
|
FROM organization_exercise_settings
|
||||||
|
WHERE organization_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(settings.unwrap_or_else(|| OrganizationExerciseSettings::defaults(organization_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_organization_exercise_settings(
|
||||||
|
pool: &PgPool,
|
||||||
|
organization_id: Uuid,
|
||||||
|
payload: &UpdateOrganizationExerciseSettingsPayload,
|
||||||
|
) -> Result<OrganizationExerciseSettings, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, OrganizationExerciseSettings>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO organization_exercise_settings (
|
||||||
|
organization_id,
|
||||||
|
audio_response_enabled,
|
||||||
|
hotspot_enabled,
|
||||||
|
memory_match_enabled,
|
||||||
|
peer_review_enabled,
|
||||||
|
role_playing_enabled,
|
||||||
|
mermaid_enabled,
|
||||||
|
code_lab_enabled,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||||
|
ON CONFLICT (organization_id) DO UPDATE SET
|
||||||
|
audio_response_enabled = EXCLUDED.audio_response_enabled,
|
||||||
|
hotspot_enabled = EXCLUDED.hotspot_enabled,
|
||||||
|
memory_match_enabled = EXCLUDED.memory_match_enabled,
|
||||||
|
peer_review_enabled = EXCLUDED.peer_review_enabled,
|
||||||
|
role_playing_enabled = EXCLUDED.role_playing_enabled,
|
||||||
|
mermaid_enabled = EXCLUDED.mermaid_enabled,
|
||||||
|
code_lab_enabled = EXCLUDED.code_lab_enabled,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING
|
||||||
|
organization_id,
|
||||||
|
audio_response_enabled,
|
||||||
|
hotspot_enabled,
|
||||||
|
memory_match_enabled,
|
||||||
|
peer_review_enabled,
|
||||||
|
role_playing_enabled,
|
||||||
|
mermaid_enabled,
|
||||||
|
code_lab_enabled
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.bind(payload.audio_response_enabled)
|
||||||
|
.bind(payload.hotspot_enabled)
|
||||||
|
.bind(payload.memory_match_enabled)
|
||||||
|
.bind(payload.peer_review_enabled)
|
||||||
|
.bind(payload.role_playing_enabled)
|
||||||
|
.bind(payload.mermaid_enabled)
|
||||||
|
.bind(payload.code_lab_enabled)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_organization_exercise_settings(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<OrganizationExerciseSettings>, (StatusCode, String)> {
|
||||||
|
let settings = load_organization_exercise_settings(&pool, org_ctx.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al cargar configuración de ejercicios: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_organization_exercise_settings(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<UpdateOrganizationExerciseSettingsPayload>,
|
||||||
|
) -> Result<Json<OrganizationExerciseSettings>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = upsert_organization_exercise_settings(&pool, org_ctx.id, &payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al guardar configuración de ejercicios: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.org,
|
||||||
|
claims.sub,
|
||||||
|
"UPDATE_EXERCISE_SETTINGS",
|
||||||
|
"Organization",
|
||||||
|
org_ctx.id,
|
||||||
|
json!({
|
||||||
|
"audio_response_enabled": settings.audio_response_enabled,
|
||||||
|
"hotspot_enabled": settings.hotspot_enabled,
|
||||||
|
"memory_match_enabled": settings.memory_match_enabled,
|
||||||
|
"peer_review_enabled": settings.peer_review_enabled,
|
||||||
|
"role_playing_enabled": settings.role_playing_enabled,
|
||||||
|
"mermaid_enabled": settings.mermaid_enabled,
|
||||||
|
"code_lab_enabled": settings.code_lab_enabled,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(settings))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod exporter;
|
|||||||
mod external_handlers;
|
mod external_handlers;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_branding;
|
mod handlers_branding;
|
||||||
|
mod handlers_exercise_settings;
|
||||||
mod handlers_assets;
|
mod handlers_assets;
|
||||||
mod handlers_dependencies;
|
mod handlers_dependencies;
|
||||||
mod handlers_library;
|
mod handlers_library;
|
||||||
@@ -296,6 +297,11 @@ async fn main() {
|
|||||||
"/organization/branding",
|
"/organization/branding",
|
||||||
axum::routing::put(handlers_branding::update_organization_branding),
|
axum::routing::put(handlers_branding::update_organization_branding),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/organization/exercise-settings",
|
||||||
|
get(handlers_exercise_settings::get_organization_exercise_settings)
|
||||||
|
.put(handlers_exercise_settings::update_organization_exercise_settings),
|
||||||
|
)
|
||||||
// Rutas de librerías de contenido
|
// Rutas de librerías de contenido
|
||||||
.route(
|
.route(
|
||||||
"/library/blocks",
|
"/library/blocks",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Play, Lock, AlertCircle } from "lucide-react";
|
import { Play, Lock, AlertCircle } from "lucide-react";
|
||||||
import { lmsApi, getCmsApiUrl } from "@/lib/api";
|
import { lmsApi, getCmsApiUrl, getImageUrl } from "@/lib/api";
|
||||||
|
|
||||||
interface MediaPlayerProps {
|
interface MediaPlayerProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,7 +49,9 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
|
|
||||||
|
|
||||||
const getFullUrl = (path: string) => {
|
const getFullUrl = (path: string) => {
|
||||||
if (path.startsWith('http')) return path;
|
if (path.startsWith('http') || path.startsWith('s3://') || path.startsWith('org/')) {
|
||||||
|
return getImageUrl(path);
|
||||||
|
}
|
||||||
// Map /uploads to /assets for the backend
|
// Map /uploads to /assets for the backend
|
||||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||||
|
|||||||
@@ -27,7 +27,59 @@ export const getCmsApiUrl = () => {
|
|||||||
|
|
||||||
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')) {
|
||||||
|
// Avoid browser CORS issues with private S3 objects by proxying through CMS.
|
||||||
|
try {
|
||||||
|
const parsed = new URL(path);
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const isAwsS3 = host.includes('.s3.') || host.endsWith('.amazonaws.com');
|
||||||
|
if (isAwsS3) {
|
||||||
|
const key = parsed.pathname.replace(/^\//, '');
|
||||||
|
let bucket = '';
|
||||||
|
|
||||||
|
// virtual-host style: <bucket>.s3.<region>.amazonaws.com
|
||||||
|
if (host.includes('.s3.')) {
|
||||||
|
bucket = host.split('.s3.')[0];
|
||||||
|
} else {
|
||||||
|
// path-style: s3.<region>.amazonaws.com/<bucket>/<key>
|
||||||
|
const [first, ...rest] = key.split('/');
|
||||||
|
if (first && rest.length) {
|
||||||
|
bucket = first;
|
||||||
|
const normalizedKey = rest.join('/');
|
||||||
|
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${normalizedKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket && key) {
|
||||||
|
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore URL parsing errors and fallback to original path.
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle persisted S3 URI format: s3://bucket/key
|
||||||
|
if (path.startsWith('s3://')) {
|
||||||
|
const withoutScheme = path.slice(5);
|
||||||
|
const firstSlash = withoutScheme.indexOf('/');
|
||||||
|
if (firstSlash > 0) {
|
||||||
|
const bucket = withoutScheme.slice(0, firstSlash);
|
||||||
|
const key = withoutScheme.slice(firstSlash + 1);
|
||||||
|
if (bucket && key) {
|
||||||
|
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plain object keys when stored directly in DB.
|
||||||
|
if (/^org\/.+/.test(path)) {
|
||||||
|
const defaultBucket = process.env.NEXT_PUBLIC_S3_BUCKET || 'openccb-802726101181-us-east-2-an';
|
||||||
|
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(defaultBucket)}/${path.replace(/^\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||||
return `${getCmsApiUrl()}${finalPath}`;
|
return `${getCmsApiUrl()}${finalPath}`;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
Gauge,
|
Gauge,
|
||||||
MessageSquareQuestion
|
MessageSquare
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -26,7 +26,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{ icon: Building2, label: "Organizations", href: "/admin" },
|
{ icon: Building2, label: "Organizations", href: "/admin" },
|
||||||
{ icon: Users, label: "Users", href: "/admin/users" },
|
{ icon: Users, label: "Users", href: "/admin/users" },
|
||||||
{ icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" },
|
{ icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" },
|
||||||
{ icon: MessageSquareQuestion, label: "FAQ Moderation", href: "/admin/faq-review" },
|
{ icon: MessageSquare, label: "FAQ Moderation", href: "/admin/faq-review" },
|
||||||
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
|
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
|
||||||
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
||||||
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
||||||
|
|||||||
@@ -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, getImageUrl, generateUUID } from '@/lib/api';
|
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, OrganizationExerciseSettings, getImageUrl, generateUUID } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -45,6 +45,17 @@ import Modal from "@/components/Modal";
|
|||||||
import MediaPlayer from "@/components/MediaPlayer";
|
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 defaultExerciseSettings: OrganizationExerciseSettings = {
|
||||||
|
organization_id: "",
|
||||||
|
audio_response_enabled: true,
|
||||||
|
hotspot_enabled: true,
|
||||||
|
memory_match_enabled: true,
|
||||||
|
peer_review_enabled: true,
|
||||||
|
role_playing_enabled: true,
|
||||||
|
mermaid_enabled: false,
|
||||||
|
code_lab_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -82,6 +93,7 @@ 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 [exerciseSettings, setExerciseSettings] = useState<OrganizationExerciseSettings>(defaultExerciseSettings);
|
||||||
|
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
@@ -92,8 +104,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
// Use cmsApi for consistency
|
// Use cmsApi for consistency
|
||||||
const lessonData = await cmsApi.getLesson(params.lessonId);
|
const [lessonData, orgExerciseSettings] = await Promise.all([
|
||||||
|
cmsApi.getLesson(params.lessonId),
|
||||||
|
cmsApi.getOrganizationExerciseSettings(),
|
||||||
|
]);
|
||||||
setLesson(lessonData);
|
setLesson(lessonData);
|
||||||
|
setExerciseSettings(orgExerciseSettings);
|
||||||
setSummary(lessonData.summary || "");
|
setSummary(lessonData.summary || "");
|
||||||
setIsGraded(lessonData.is_graded || false);
|
setIsGraded(lessonData.is_graded || false);
|
||||||
setSelectedCategoryId(lessonData.grading_category_id || "");
|
setSelectedCategoryId(lessonData.grading_category_id || "");
|
||||||
@@ -151,6 +167,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
loadData();
|
loadData();
|
||||||
}, [params.id, params.lessonId]);
|
}, [params.id, params.lessonId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!exerciseSettings.role_playing_enabled && aiQuizType === 'role-playing') {
|
||||||
|
setAiQuizType('multiple-choice');
|
||||||
|
}
|
||||||
|
}, [exerciseSettings.role_playing_enabled, aiQuizType]);
|
||||||
|
|
||||||
const handleSaveLessonTitle = async () => {
|
const handleSaveLessonTitle = async () => {
|
||||||
if (!lesson || !editValue) return;
|
if (!lesson || !editValue) return;
|
||||||
try {
|
try {
|
||||||
@@ -220,6 +242,20 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addBlock = (type: Block['type']) => {
|
const addBlock = (type: Block['type']) => {
|
||||||
|
const blockedBySettings =
|
||||||
|
(type === 'audio-response' && !exerciseSettings.audio_response_enabled) ||
|
||||||
|
(type === 'hotspot' && !exerciseSettings.hotspot_enabled) ||
|
||||||
|
(type === 'memory-match' && !exerciseSettings.memory_match_enabled) ||
|
||||||
|
(type === 'peer-review' && !exerciseSettings.peer_review_enabled) ||
|
||||||
|
(type === 'role-playing' && !exerciseSettings.role_playing_enabled) ||
|
||||||
|
(type === 'mermaid' && !exerciseSettings.mermaid_enabled) ||
|
||||||
|
(type === 'code-lab' && !exerciseSettings.code_lab_enabled);
|
||||||
|
|
||||||
|
if (blockedBySettings) {
|
||||||
|
alert('Este tipo de ejercicio está desactivado para tu organización.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newBlock: Block = {
|
const newBlock: Block = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
type,
|
type,
|
||||||
@@ -338,6 +374,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
setIsGeneratingQuiz(true);
|
setIsGeneratingQuiz(true);
|
||||||
try {
|
try {
|
||||||
if (aiQuizType === 'role-playing') {
|
if (aiQuizType === 'role-playing') {
|
||||||
|
if (!exerciseSettings.role_playing_enabled) {
|
||||||
|
throw new Error('Role Playing está desactivado para esta organización.');
|
||||||
|
}
|
||||||
const data = await cmsApi.generateRolePlay(lesson.id, {
|
const data = await cmsApi.generateRolePlay(lesson.id, {
|
||||||
prompt_hint: aiQuizContext
|
prompt_hint: aiQuizContext
|
||||||
});
|
});
|
||||||
@@ -1074,6 +1113,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
courseId={params.id}
|
courseId={params.id}
|
||||||
lessonId={params.lessonId}
|
lessonId={params.lessonId}
|
||||||
|
aiGenerationEnabled={exerciseSettings.hotspot_enabled}
|
||||||
onChange={(updates) => updateBlock(block.id, updates)}
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1105,6 +1145,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
lessonId={lesson.id}
|
lessonId={lesson.id}
|
||||||
courseId={params.id}
|
courseId={params.id}
|
||||||
|
aiGenerationEnabled={exerciseSettings.mermaid_enabled}
|
||||||
onChange={(updates) => updateBlock(block.id, updates)}
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1112,6 +1153,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<RolePlayingBlock
|
<RolePlayingBlock
|
||||||
block={block}
|
block={block}
|
||||||
lessonId={params.lessonId}
|
lessonId={params.lessonId}
|
||||||
|
aiGenerationEnabled={exerciseSettings.role_playing_enabled}
|
||||||
onUpdate={(updates) => updateBlock(block.id, updates)}
|
onUpdate={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1126,6 +1168,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
test_cases={block.test_cases}
|
test_cases={block.test_cases}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
lessonId={params.lessonId}
|
lessonId={params.lessonId}
|
||||||
|
aiGenerationEnabled={exerciseSettings.code_lab_enabled}
|
||||||
onChange={(updates) => updateBlock(block.id, updates)}
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1167,12 +1210,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
{ type: 'matching', icon: '🔗', label: 'Relations', color: 'violet' },
|
{ type: 'matching', icon: '🔗', label: 'Relations', color: 'violet' },
|
||||||
{ type: 'ordering', icon: '🔢', label: 'Sequence', color: 'blue' },
|
{ type: 'ordering', icon: '🔢', label: 'Sequence', color: 'blue' },
|
||||||
{ type: 'short-answer', icon: '💬', label: 'Open-Ended', color: 'indigo' },
|
{ type: 'short-answer', icon: '💬', label: 'Open-Ended', color: 'indigo' },
|
||||||
{ type: 'hotspot', icon: '🎯', label: 'Hotspot', color: 'amber' },
|
...(exerciseSettings.hotspot_enabled ? [{ type: 'hotspot', icon: '🎯', label: 'Hotspot', color: 'amber' }] : []),
|
||||||
{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' },
|
...(exerciseSettings.audio_response_enabled ? [{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' }] : []),
|
||||||
{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' },
|
...(exerciseSettings.memory_match_enabled ? [{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' }] : []),
|
||||||
{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' },
|
...(exerciseSettings.peer_review_enabled ? [{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' }] : []),
|
||||||
{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' },
|
...(exerciseSettings.mermaid_enabled ? [{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' }] : []),
|
||||||
{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' },
|
...(exerciseSettings.role_playing_enabled ? [{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' }] : []),
|
||||||
|
...(exerciseSettings.code_lab_enabled ? [{ type: 'code-lab', icon: '💻', label: 'Code Lab', color: 'slate' }] : []),
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.type}
|
key={item.type}
|
||||||
@@ -1265,7 +1309,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<option value="vocabulary">Lexical Focus / Vocab</option>
|
<option value="vocabulary">Lexical Focus / Vocab</option>
|
||||||
<option value="grammar">Structural / Grammar Focus</option>
|
<option value="grammar">Structural / Grammar Focus</option>
|
||||||
<option value="memory-match">Conceptual Memory Match</option>
|
<option value="memory-match">Conceptual Memory Match</option>
|
||||||
<option value="role-playing">AI Role-Playing Simulation</option>
|
{exerciseSettings.role_playing_enabled && (
|
||||||
|
<option value="role-playing">AI Role-Playing Simulation</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
||||||
<ChevronDown size={18} />
|
<ChevronDown size={18} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import BrandingSettings from "@/components/BrandingSettings";
|
import BrandingSettings from "@/components/BrandingSettings";
|
||||||
|
import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings";
|
||||||
import PageLayout from "@/components/PageLayout";
|
import PageLayout from "@/components/PageLayout";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -22,10 +23,13 @@ export default function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
title="Configuración de Organización"
|
title="Configuración de Organización"
|
||||||
description="Gestiona el branding y la identidad de tu plataforma."
|
description="Gestiona el branding y la disponibilidad de ejercicios de tu plataforma."
|
||||||
maxWidth="narrow"
|
maxWidth="narrow"
|
||||||
>
|
>
|
||||||
<BrandingSettings />
|
<div className="space-y-8">
|
||||||
|
<BrandingSettings />
|
||||||
|
<ExerciseFeatureSettings />
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cmsApi, OrganizationExerciseSettings } from "@/lib/api";
|
||||||
|
|
||||||
|
const featureCards: Array<{
|
||||||
|
key: keyof Omit<OrganizationExerciseSettings, "organization_id">;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "audio_response_enabled",
|
||||||
|
title: "Audio Response",
|
||||||
|
description: "Permite ejercicios donde el alumno responde grabando audio.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hotspot_enabled",
|
||||||
|
title: "Hotspot",
|
||||||
|
description: "Permite actividades visuales con puntos interactivos sobre imágenes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "memory_match_enabled",
|
||||||
|
title: "Memory Match",
|
||||||
|
description: "Permite juegos de memoria y emparejamiento visual.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "peer_review_enabled",
|
||||||
|
title: "Peer Review",
|
||||||
|
description: "Permite actividades donde estudiantes revisan entregas de otros.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "role_playing_enabled",
|
||||||
|
title: "Role Playing",
|
||||||
|
description: "Permite simulaciones de conversación con IA.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mermaid_enabled",
|
||||||
|
title: "Mermaid Diagram",
|
||||||
|
description: "Permite diagramas Mermaid y su generación asistida.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "code_lab_enabled",
|
||||||
|
title: "Code Lab",
|
||||||
|
description: "Permite laboratorios de código generados o editados manualmente.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultSettings: OrganizationExerciseSettings = {
|
||||||
|
organization_id: "",
|
||||||
|
audio_response_enabled: true,
|
||||||
|
hotspot_enabled: true,
|
||||||
|
memory_match_enabled: true,
|
||||||
|
peer_review_enabled: true,
|
||||||
|
role_playing_enabled: true,
|
||||||
|
mermaid_enabled: false,
|
||||||
|
code_lab_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExerciseFeatureSettings() {
|
||||||
|
const [settings, setSettings] = useState<OrganizationExerciseSettings>(defaultSettings);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const data = await cmsApi.getOrganizationExerciseSettings();
|
||||||
|
setSettings(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load exercise settings:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFeature = (key: keyof Omit<OrganizationExerciseSettings, "organization_id">) => {
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
audio_response_enabled: settings.audio_response_enabled,
|
||||||
|
hotspot_enabled: settings.hotspot_enabled,
|
||||||
|
memory_match_enabled: settings.memory_match_enabled,
|
||||||
|
peer_review_enabled: settings.peer_review_enabled,
|
||||||
|
role_playing_enabled: settings.role_playing_enabled,
|
||||||
|
mermaid_enabled: settings.mermaid_enabled,
|
||||||
|
code_lab_enabled: settings.code_lab_enabled,
|
||||||
|
};
|
||||||
|
const updated = await cmsApi.updateOrganizationExerciseSettings(payload);
|
||||||
|
setSettings(updated);
|
||||||
|
alert("Disponibilidad de ejercicios actualizada correctamente.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update exercise settings:", error);
|
||||||
|
alert("No se pudo guardar la disponibilidad de ejercicios.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8 text-center text-gray-400 animate-pulse">Cargando disponibilidad de ejercicios...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="border border-slate-200 dark:border-white/10 rounded-2xl p-6 bg-white dark:bg-white/5 backdrop-blur-sm shadow-sm">
|
||||||
|
<legend className="px-2 text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
|
||||||
|
<span aria-hidden="true">🧩</span> Ejercicios Disponibles
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-gray-400">
|
||||||
|
Activa o desactiva por organización qué tipos de ejercicios pueden usarse en el constructor de lecciones y en sus generadores asociados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||||
|
{featureCards.map((feature) => {
|
||||||
|
const enabled = settings[feature.key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={feature.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFeature(feature.key)}
|
||||||
|
className={`rounded-xl border p-4 text-left transition-all ${
|
||||||
|
enabled
|
||||||
|
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-500/10 dark:border-emerald-500/30"
|
||||||
|
: "bg-slate-50 border-slate-200 dark:bg-black/20 dark:border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 dark:text-white">{feature.title}</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`h-7 w-12 rounded-full p-1 transition-all ${enabled ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-700"}`}>
|
||||||
|
<div className={`h-5 w-5 rounded-full bg-white transition-transform ${enabled ? "translate-x-5" : "translate-x-0"}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-gray-400">
|
||||||
|
{enabled ? "Activo" : "Inactivo"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Guardando..." : "Guardar disponibilidad"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ interface CodeLabBlockProps {
|
|||||||
test_cases?: { description: string; expected: string }[];
|
test_cases?: { description: string; expected: string }[];
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
|
aiGenerationEnabled?: boolean;
|
||||||
onChange: (updates: {
|
onChange: (updates: {
|
||||||
title?: string;
|
title?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -34,12 +35,17 @@ export default function CodeLabBlock({
|
|||||||
test_cases = [],
|
test_cases = [],
|
||||||
editMode,
|
editMode,
|
||||||
lessonId,
|
lessonId,
|
||||||
|
aiGenerationEnabled = true,
|
||||||
onChange
|
onChange
|
||||||
}: CodeLabBlockProps) {
|
}: CodeLabBlockProps) {
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [promptHint, setPromptHint] = useState("");
|
const [promptHint, setPromptHint] = useState("");
|
||||||
|
|
||||||
const handleGenerateAI = async () => {
|
const handleGenerateAI = async () => {
|
||||||
|
if (!aiGenerationEnabled) {
|
||||||
|
alert("Code Lab está desactivado para esta organización.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const data = await cmsApi.generateCodeLab(lessonId, {
|
const data = await cmsApi.generateCodeLab(lessonId, {
|
||||||
@@ -157,15 +163,21 @@ export default function CodeLabBlock({
|
|||||||
|
|
||||||
<div className="pt-6 border-t border-slate-100 dark:border-white/5 space-y-4">
|
<div className="pt-6 border-t border-slate-100 dark:border-white/5 space-y-4">
|
||||||
<h4 className="text-sm font-black text-slate-800 dark:text-white uppercase tracking-tight">Generación con IA</h4>
|
<h4 className="text-sm font-black text-slate-800 dark:text-white uppercase tracking-tight">Generación con IA</h4>
|
||||||
|
{!aiGenerationEnabled && (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">
|
||||||
|
Code Lab está desactivado para esta organización. Puedes seguir editando el bloque manualmente.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
value={promptHint}
|
value={promptHint}
|
||||||
onChange={(e) => setPromptHint(e.target.value)}
|
onChange={(e) => setPromptHint(e.target.value)}
|
||||||
placeholder="Ej. Crea un ejercicio sobre bucles for que use una lista de tareas..."
|
placeholder="Ej. Crea un ejercicio sobre bucles for que use una lista de tareas..."
|
||||||
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm text-slate-700 dark:text-gray-300 min-h-[80px] outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm text-slate-700 dark:text-gray-300 min-h-[80px] outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all disabled:opacity-60"
|
||||||
|
disabled={!aiGenerationEnabled}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateAI}
|
onClick={handleGenerateAI}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || !aiGenerationEnabled}
|
||||||
className="flex w-full justify-center items-center gap-2 px-6 py-4 bg-indigo-600 text-white rounded-2xl text-[11px] font-black uppercase tracking-widest hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-xl shadow-indigo-500/20"
|
className="flex w-full justify-center items-center gap-2 px-6 py-4 bg-indigo-600 text-white rounded-2xl text-[11px] font-black uppercase tracking-widest hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-xl shadow-indigo-500/20"
|
||||||
>
|
>
|
||||||
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
|
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface HotspotBlockProps {
|
|||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
|
aiGenerationEnabled?: boolean;
|
||||||
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
|
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export default function HotspotBlock({
|
|||||||
editMode,
|
editMode,
|
||||||
courseId,
|
courseId,
|
||||||
lessonId,
|
lessonId,
|
||||||
|
aiGenerationEnabled = true,
|
||||||
onChange
|
onChange
|
||||||
}: HotspotBlockProps) {
|
}: HotspotBlockProps) {
|
||||||
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
||||||
@@ -42,10 +44,14 @@ export default function HotspotBlock({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleGenerateAI = async () => {
|
const handleGenerateAI = async () => {
|
||||||
|
if (!aiGenerationEnabled) {
|
||||||
|
alert("Hotspot está desactivado para esta organización.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl });
|
const data = await cmsApi.generateHotspots(lessonId, { image_url: getImageUrl(imageUrl) });
|
||||||
// Handle different response formats from AI
|
// Handle different response formats from AI
|
||||||
const raw: any = data;
|
const raw: any = data;
|
||||||
let hotspotsArray = Array.isArray(raw) ? raw : (raw.hotspots || raw.items || []);
|
let hotspotsArray = Array.isArray(raw) ? raw : (raw.hotspots || raw.items || []);
|
||||||
@@ -160,7 +166,7 @@ export default function HotspotBlock({
|
|||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateAI}
|
onClick={handleGenerateAI}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || !aiGenerationEnabled}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-[9px] font-black uppercase tracking-widest hover:bg-amber-700 transition-all disabled:opacity-50 shadow-lg shadow-amber-500/20 active:scale-95"
|
className="flex items-center gap-2 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-[9px] font-black uppercase tracking-widest hover:bg-amber-700 transition-all disabled:opacity-50 shadow-lg shadow-amber-500/20 active:scale-95"
|
||||||
>
|
>
|
||||||
{isGenerating ? <Loader2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
|
{isGenerating ? <Loader2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface MermaidBlockProps {
|
|||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
|
aiGenerationEnabled?: boolean;
|
||||||
onChange: (updates: { title?: string; description?: string; mermaid_code?: string }) => void;
|
onChange: (updates: { title?: string; description?: string; mermaid_code?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export default function MermaidBlock({
|
|||||||
mermaid_code = "",
|
mermaid_code = "",
|
||||||
editMode,
|
editMode,
|
||||||
lessonId,
|
lessonId,
|
||||||
|
aiGenerationEnabled = true,
|
||||||
onChange
|
onChange
|
||||||
}: MermaidBlockProps) {
|
}: MermaidBlockProps) {
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
@@ -59,6 +61,10 @@ export default function MermaidBlock({
|
|||||||
}, [mermaid_code, editMode]);
|
}, [mermaid_code, editMode]);
|
||||||
|
|
||||||
const handleGenerateAI = async () => {
|
const handleGenerateAI = async () => {
|
||||||
|
if (!aiGenerationEnabled) {
|
||||||
|
alert("La generación de diagramas Mermaid está desactivada para esta organización.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const data = await cmsApi.generateMermaidDiagram(lessonId, { prompt_hint: promptHint || undefined });
|
const data = await cmsApi.generateMermaidDiagram(lessonId, { prompt_hint: promptHint || undefined });
|
||||||
@@ -135,17 +141,23 @@ export default function MermaidBlock({
|
|||||||
|
|
||||||
<div className="space-y-4 pt-6 border-t border-slate-100 dark:border-white/5">
|
<div className="space-y-4 pt-6 border-t border-slate-100 dark:border-white/5">
|
||||||
<h4 className="text-sm font-black text-slate-800 dark:text-white uppercase tracking-tight">Generación con IA</h4>
|
<h4 className="text-sm font-black text-slate-800 dark:text-white uppercase tracking-tight">Generación con IA</h4>
|
||||||
|
{!aiGenerationEnabled && (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">
|
||||||
|
Mermaid está desactivado para esta organización. Puedes conservar o editar código existente manualmente.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Instrucciones extra (Opcional)</label>
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Instrucciones extra (Opcional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={promptHint}
|
value={promptHint}
|
||||||
onChange={(e) => setPromptHint(e.target.value)}
|
onChange={(e) => setPromptHint(e.target.value)}
|
||||||
placeholder="Ej. Crea un mapa mental sobre los conceptos clave..."
|
placeholder="Ej. Crea un mapa mental sobre los conceptos clave..."
|
||||||
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-medium text-slate-700 dark:text-gray-300 min-h-[100px] resize-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 outline-none"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-medium text-slate-700 dark:text-gray-300 min-h-[100px] resize-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 outline-none disabled:opacity-60"
|
||||||
|
disabled={!aiGenerationEnabled}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateAI}
|
onClick={handleGenerateAI}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || !aiGenerationEnabled}
|
||||||
className="flex w-full justify-center items-center gap-2 px-6 py-4 bg-indigo-600 text-white rounded-2xl text-[11px] font-black uppercase tracking-widest hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-xl shadow-indigo-500/20 active:scale-95"
|
className="flex w-full justify-center items-center gap-2 px-6 py-4 bg-indigo-600 text-white rounded-2xl text-[11px] font-black uppercase tracking-widest hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-xl shadow-indigo-500/20 active:scale-95"
|
||||||
>
|
>
|
||||||
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
|
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ interface RolePlayingBlockProps {
|
|||||||
block: Block;
|
block: Block;
|
||||||
onUpdate: (updates: Partial<Block>) => void;
|
onUpdate: (updates: Partial<Block>) => void;
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
|
aiGenerationEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RolePlayingBlock({ block, onUpdate, lessonId }: RolePlayingBlockProps) {
|
export default function RolePlayingBlock({ block, onUpdate, lessonId, aiGenerationEnabled = true }: RolePlayingBlockProps) {
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
const handleGenerateAI = async () => {
|
const handleGenerateAI = async () => {
|
||||||
|
if (!aiGenerationEnabled) {
|
||||||
|
alert("Role Playing está desactivado para esta organización.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const data = await cmsApi.generateRolePlay(lessonId, {});
|
const data = await cmsApi.generateRolePlay(lessonId, {});
|
||||||
@@ -47,7 +52,7 @@ export default function RolePlayingBlock({ block, onUpdate, lessonId }: RolePlay
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateAI}
|
onClick={handleGenerateAI}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || !aiGenerationEnabled}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||||
>
|
>
|
||||||
{isGenerating ? <Loader2 className="animate-spin" size={14} /> : <Wand2 size={14} />}
|
{isGenerating ? <Loader2 className="animate-spin" size={14} /> : <Wand2 size={14} />}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
|
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
|
||||||
import MediaPlayer from "../MediaPlayer";
|
import MediaPlayer from "../MediaPlayer";
|
||||||
|
import FileUpload from "../FileUpload";
|
||||||
|
import { getImageUrl } from "@/lib/api";
|
||||||
|
|
||||||
interface VideoMarker {
|
interface VideoMarker {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -15,7 +17,7 @@ interface VideoMarkerBlockProps {
|
|||||||
title: string;
|
title: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
markers: VideoMarker[];
|
markers: VideoMarker[];
|
||||||
onChange: (updates: { title?: string; markers?: VideoMarker[] }) => void;
|
onChange: (updates: { title?: string; url?: string; markers?: VideoMarker[] }) => void;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
isGraded?: boolean;
|
isGraded?: boolean;
|
||||||
}
|
}
|
||||||
@@ -30,6 +32,13 @@ export default function VideoMarkerBlock({
|
|||||||
}: VideoMarkerBlockProps) {
|
}: VideoMarkerBlockProps) {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
const [sourceType, setSourceType] = useState<"url" | "upload">(
|
||||||
|
(videoUrl.startsWith("/assets/") || videoUrl.includes("/assets/") || videoUrl.startsWith("s3://") || /^org\/.+/.test(videoUrl))
|
||||||
|
? "upload"
|
||||||
|
: "url"
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayVideoUrl = getImageUrl(videoUrl);
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@@ -46,6 +55,11 @@ export default function VideoMarkerBlock({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addMarker = () => {
|
const addMarker = () => {
|
||||||
|
if (!videoUrl) {
|
||||||
|
alert("Primero agrega o sube un video para poder insertar marcadores.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newMarker: VideoMarker = {
|
const newMarker: VideoMarker = {
|
||||||
timestamp: currentTime,
|
timestamp: currentTime,
|
||||||
question: "Nueva pregunta",
|
question: "Nueva pregunta",
|
||||||
@@ -119,6 +133,45 @@ export default function VideoMarkerBlock({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 p-8 bg-white dark:bg-white/5 border border-indigo-500/10 dark:border-indigo-500/20 rounded-[2rem] shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceType("url")}
|
||||||
|
className={`px-6 py-2 text-[10px] uppercase font-black tracking-[0.2em] rounded-xl transition-all border ${sourceType === "url" ? "bg-indigo-600 text-white border-indigo-600 shadow-lg shadow-indigo-500/30" : "bg-slate-50 dark:bg-white/5 text-slate-400 dark:text-gray-500 border-slate-100 hover:border-slate-200"}`}
|
||||||
|
>
|
||||||
|
External Stream
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceType("upload")}
|
||||||
|
className={`px-6 py-2 text-[10px] uppercase font-black tracking-[0.2em] rounded-xl transition-all border ${sourceType === "upload" ? "bg-indigo-600 text-white border-indigo-600 shadow-lg shadow-indigo-500/30" : "bg-slate-50 dark:bg-white/5 text-slate-400 dark:text-gray-500 border-slate-100 hover:border-slate-200"}`}
|
||||||
|
>
|
||||||
|
Direct Asset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceType === "url" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Video Source</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={videoUrl.startsWith("/") ? "" : videoUrl}
|
||||||
|
onChange={(e) => onChange({ url: e.target.value })}
|
||||||
|
placeholder="YouTube, Vimeo or direct video URL"
|
||||||
|
className="w-full bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all outline-none shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Video Upload</label>
|
||||||
|
<FileUpload
|
||||||
|
currentUrl={videoUrl.startsWith("/") ? videoUrl : undefined}
|
||||||
|
accept="video/*"
|
||||||
|
onUploadComplete={(newUrl) => onChange({ url: newUrl })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Video Preview with Timeline */}
|
{/* Video Preview with Timeline */}
|
||||||
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 p-6 rounded-[3rem] space-y-6 shadow-xl relative overflow-hidden">
|
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 p-6 rounded-[3rem] space-y-6 shadow-xl relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/5 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2"></div>
|
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/5 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2"></div>
|
||||||
@@ -132,7 +185,7 @@ export default function VideoMarkerBlock({
|
|||||||
|
|
||||||
<div className="rounded-[2rem] overflow-hidden border border-slate-100 dark:border-white/10 shadow-2xl relative z-10">
|
<div className="rounded-[2rem] overflow-hidden border border-slate-100 dark:border-white/10 shadow-2xl relative z-10">
|
||||||
<MediaPlayer
|
<MediaPlayer
|
||||||
src={videoUrl}
|
src={displayVideoUrl}
|
||||||
type="video"
|
type="video"
|
||||||
isGraded={isGraded}
|
isGraded={isGraded}
|
||||||
showInteractive={false}
|
showInteractive={false}
|
||||||
|
|||||||
@@ -269,6 +269,17 @@ export interface BrandingResponse {
|
|||||||
secondary_color: string;
|
secondary_color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrganizationExerciseSettings {
|
||||||
|
organization_id: string;
|
||||||
|
audio_response_enabled: boolean;
|
||||||
|
hotspot_enabled: boolean;
|
||||||
|
memory_match_enabled: boolean;
|
||||||
|
peer_review_enabled: boolean;
|
||||||
|
role_playing_enabled: boolean;
|
||||||
|
mermaid_enabled: boolean;
|
||||||
|
code_lab_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -863,6 +874,9 @@ export const cmsApi = {
|
|||||||
},
|
},
|
||||||
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
|
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
|
||||||
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
|
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
getOrganizationExerciseSettings: (): Promise<OrganizationExerciseSettings> => apiFetch('/organization/exercise-settings'),
|
||||||
|
updateOrganizationExerciseSettings: (payload: Omit<OrganizationExerciseSettings, 'organization_id'>): Promise<OrganizationExerciseSettings> =>
|
||||||
|
apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user