feat: se aplican varios fix a las pruebas
This commit is contained in:
@@ -22,6 +22,12 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
- **Smart Notifications**: Recordatorios de fechas límite y alertas in-app.
|
- **Smart Notifications**: Recordatorios de fechas límite y alertas in-app.
|
||||||
- **Global i18n**: Interfaz multilingüe (EN, ES, PT) con persistencia por usuario.
|
- **Global i18n**: Interfaz multilingüe (EN, ES, PT) con persistencia por usuario.
|
||||||
- **Document-Based Learning**: Soporte para actividades de lectura (PDF, DOCX, PPTX).
|
- **Document-Based Learning**: Soporte para actividades de lectura (PDF, DOCX, PPTX).
|
||||||
|
- **AI English Teacher**: Persona especializada para generación de contenidos y tutoría personalizada.
|
||||||
|
- **AI Audio Evaluation**: Evaluación inteligente de pronunciación y contenido con feedback en lenguaje natural.
|
||||||
|
- **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.).
|
||||||
|
- **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada.
|
||||||
|
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
|
||||||
|
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
|
||||||
|
|
||||||
## Requisitos del Sistema
|
## Requisitos del Sistema
|
||||||
|
|
||||||
@@ -282,6 +288,22 @@ Funcionalidades inteligentes 100% locales y gratuitas.
|
|||||||
#### POST /lessons/{id}/transcribe
|
#### POST /lessons/{id}/transcribe
|
||||||
Inicia el proceso de transcripción y traducción para una lección de video/audio.
|
Inicia el proceso de transcripción y traducción para una lección de video/audio.
|
||||||
|
|
||||||
|
#### POST /audio/evaluate
|
||||||
|
Evalúa una respuesta oral del estudiante utilizando IA.
|
||||||
|
|
||||||
|
#### POST /lessons/{id}/generate-quiz
|
||||||
|
Genera un quiz basado en el contenido de la lección.
|
||||||
|
- **Cuerpo ( QuizAIRequest ):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"context": "focused on irregular verbs",
|
||||||
|
"quiz_type": "true-false"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE /courses/{id}
|
||||||
|
Elimina un curso y todos sus contenidos relacionados (módulos, lecciones, assets).
|
||||||
|
|
||||||
- **Procesamiento Asíncrono**: Despacha una tarea en segundo plano que utiliza Whisper para transcripción y Ollama para generar la traducción y el resumen inteligente.
|
- **Procesamiento Asíncrono**: Despacha una tarea en segundo plano que utiliza Whisper para transcripción y Ollama para generar la traducción y el resumen inteligente.
|
||||||
- **Cuerpo de la Petición**: Vacío.
|
- **Cuerpo de la Petición**: Vacío.
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -130,7 +130,10 @@
|
|||||||
- [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado)
|
- [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado)
|
||||||
- [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado)
|
- [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado)
|
||||||
- [ ] **Tutor de IA Integrado**: Asistente basado en RAG dentro del reproductor de lecciones
|
- [ ] **Tutor de IA Integrado**: Asistente basado en RAG dentro del reproductor de lecciones
|
||||||
- [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas (Completado)
|
- [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas con feedback de IA detallado (Completado)
|
||||||
|
- [x] **Eliminación de Cursos**: Gestión completa del ciclo de vida del contenido (Completado)
|
||||||
|
- [x] **Quices con Contexto IA**: Generación de evaluaciones con enfoque y tipo personalizable (Completado)
|
||||||
|
- [x] **Actividades Gamificadas**: Nuevos bloques de Juego de Memoria e Identificación Visual (Hotspots) (Completado)
|
||||||
- [x] **Marcadores de Video**: Preguntas que pausan el video en timestamps específicos (Completado)
|
- [x] **Marcadores de Video**: Preguntas que pausan el video en timestamps específicos (Completado)
|
||||||
|
|
||||||
## Fase 12: Generador de Cursos "Mágico" con IA ✅
|
## Fase 12: Generador de Cursos "Mágico" con IA ✅
|
||||||
@@ -151,11 +154,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Estado Actual y Próximas Prioridades
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado que permite la generación de contenidos con una "Persona" docente experta, evaluaciones de audio automatizadas y quices personalizables con contexto. Se ha completado el ciclo de vida de gestión de cursos con la integración de la funcionalidad de borrado.
|
||||||
|
|
||||||
**Madurez de la Plataforma**: El núcleo multi-tenant es estable, escalable y está optimizado para alto rendimiento con IA Local.
|
**Próximas Prioridades**:
|
||||||
|
1. **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción.
|
||||||
**Prioridades Inmediatas**:
|
|
||||||
1. **Marcadores de Video & Audio**: Evaluaciones integradas en contenido multimedia.
|
|
||||||
2. **IA Teaching Assistant**: Tutor RAG personalizado por curso.
|
2. **IA Teaching Assistant**: Tutor RAG personalizado por curso.
|
||||||
3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial.
|
3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial.
|
||||||
|
|||||||
@@ -189,6 +189,17 @@ pub struct GradingPayload {
|
|||||||
pub drop_count: i32,
|
pub drop_count: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct QuizAIRequest {
|
||||||
|
pub context: Option<String>,
|
||||||
|
pub quiz_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ReviewTextRequest {
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_course(
|
pub async fn create_course(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
@@ -1000,6 +1011,7 @@ pub async fn generate_quiz(
|
|||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
Json(quiz_req): Json<QuizAIRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
tracing::info!("Received quiz generation request for lesson: {}", id);
|
tracing::info!("Received quiz generation request for lesson: {}", id);
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
@@ -1049,6 +1061,20 @@ pub async fn generate_quiz(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut system_prompt = "You are an expert English Teacher. Generate 3 questions based on the lesson content to test the student's understanding of English grammar, vocabulary, or comprehension. Instructions can be in Spanish or English. Return ONLY a JSON object with a field 'blocks' which is an array of content blocks. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correct\": [0], \"explanation\": \"Explain why the answer is correct.\" } ] } }. Important: 'correct' MUST be an array of integers.".to_string();
|
||||||
|
|
||||||
|
if let Some(ctx) = &quiz_req.context {
|
||||||
|
if !ctx.is_empty() {
|
||||||
|
system_prompt.push_str(&format!(" Additional Context: {}", ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(qtype) = &quiz_req.quiz_type {
|
||||||
|
if !qtype.is_empty() {
|
||||||
|
system_prompt.push_str(&format!(" Question Type to use: {}. If the type is 'multiple-choice', follow the structure above. If it's something else, adapt the block 'type' (e.g., 'true-false') accordingly, but keep it within the 'blocks' array.", qtype));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut request = client
|
let mut request = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -1056,7 +1082,7 @@ pub async fn generate_quiz(
|
|||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "You are an expert English Teacher. Generate 3 multiple-choice questions based on the lesson content to test the student's understanding of English grammar, vocabulary, or comprehension. Instructions can be in Spanish or English. Return ONLY a JSON object with a field 'blocks' which is an array of content blocks. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correct\": [0], \"explanation\": \"Explain why the answer is correct.\" } ] } }. Important: 'correct' MUST be an array of integers."
|
"content": system_prompt
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -3282,3 +3308,172 @@ RULES:
|
|||||||
|
|
||||||
Ok(Json(course))
|
Ok(Json(course))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_course(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let is_super_admin = claims.role == "admin"
|
||||||
|
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
|
|
||||||
|
// 1. Check if course exists and belongs to org (or if requester is super admin)
|
||||||
|
let course = if is_super_admin {
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
// 2. Additional permission check for instructors
|
||||||
|
if !is_super_admin && claims.role == "instructor" && course.instructor_id != claims.sub {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete course using DB function
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let ip = headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok()))
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let ua = headers
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
crate::db_util::set_session_context(
|
||||||
|
&mut conn,
|
||||||
|
Some(claims.sub),
|
||||||
|
Some(org_ctx.id),
|
||||||
|
ip,
|
||||||
|
ua,
|
||||||
|
Some("USER_EVENT".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_course($1, $2)")
|
||||||
|
.bind(id)
|
||||||
|
.bind(course.organization_id)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Delete course failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
|
claims.sub,
|
||||||
|
"DELETE",
|
||||||
|
"Course",
|
||||||
|
id,
|
||||||
|
json!({ "title": course.title }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_text(
|
||||||
|
_: Org,
|
||||||
|
_claims: Claims,
|
||||||
|
State(_pool): State<PgPool>,
|
||||||
|
Json(payload): Json<ReviewTextRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
if payload.text.trim().is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let (url, auth_header, model) = if provider == "local" {
|
||||||
|
let base_url =
|
||||||
|
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
|
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string());
|
||||||
|
(
|
||||||
|
format!("{}/v1/chat/completions", base_url),
|
||||||
|
"".to_string(),
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
format!("Bearer {}", api_key),
|
||||||
|
"gpt-4o".to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let system_prompt = "You are an expert English Teacher and Editor. Analyze the following text and provide suggestions for improvement. Focus on: 1. Grammar and spelling. 2. Tone (should be professional yet encouraging). 3. Clarity and conciseness. 4. Better vocabulary choices. Return ONLY a JSON object with a field 'suggestion' containing the improved version of the text, and a field 'comments' which is a brief list of what was improved.";
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": system_prompt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": payload.text
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_format": { "type": "json_object" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if !auth_header.is_empty() {
|
||||||
|
request = request.header("Authorization", auth_header);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
tracing::error!("Text review request failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::error!("Text review API error: {}", err_body);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
let review_data: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let content_str = review_data["choices"][0]["message"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("{}");
|
||||||
|
|
||||||
|
let parsed_review: serde_json::Value = serde_json::from_str(content_str).unwrap_or(json!({
|
||||||
|
"suggestion": payload.text,
|
||||||
|
"comments": "No suggestions available at this time."
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(Json(parsed_review))
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/courses/{id}",
|
"/courses/{id}",
|
||||||
get(handlers::get_course).put(handlers::update_course),
|
get(handlers::get_course)
|
||||||
|
.put(handlers::update_course)
|
||||||
|
.delete(handlers::delete_course),
|
||||||
)
|
)
|
||||||
.route("/courses/{id}/publish", post(handlers::publish_course))
|
.route("/courses/{id}/publish", post(handlers::publish_course))
|
||||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||||
@@ -140,6 +142,7 @@ async fn main() {
|
|||||||
.route("/users", get(handlers::get_all_users))
|
.route("/users", get(handlers::get_all_users))
|
||||||
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
|
.route("/api/ai/review-text", post(handlers::review_text))
|
||||||
.route("/api/assets/upload", post(handlers::upload_asset))
|
.route("/api/assets/upload", post(handlers::upload_asset))
|
||||||
.route("/api/assets/{id}", delete(handlers::delete_asset))
|
.route("/api/assets/{id}", delete(handlers::delete_asset))
|
||||||
.route("/courses/{id}/assets", get(handlers::get_course_assets))
|
.route("/courses/{id}/assets", get(handlers::get_course_assets))
|
||||||
|
|||||||
Generated
+12
@@ -568,6 +568,7 @@
|
|||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -630,6 +631,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -1103,6 +1105,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1513,6 +1516,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2211,6 +2215,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -2373,6 +2378,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3797,6 +3803,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -5138,6 +5145,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5338,6 +5346,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5349,6 +5358,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -6291,6 +6301,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6468,6 +6479,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -301,9 +301,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
return (
|
return (
|
||||||
<HotspotPlayer
|
<HotspotPlayer
|
||||||
title={block.title}
|
title={block.title}
|
||||||
description={block.content || ""}
|
description={block.description || ""}
|
||||||
imageUrl={block.url || ""}
|
imageUrl={block.imageUrl || ""}
|
||||||
hotspots={block.metadata?.hotspots || []}
|
hotspots={block.hotspots || []}
|
||||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -311,7 +311,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
return (
|
return (
|
||||||
<MemoryPlayer
|
<MemoryPlayer
|
||||||
title={block.title}
|
title={block.title}
|
||||||
pairs={block.metadata?.pairs || []}
|
pairs={block.pairs || []}
|
||||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Search, CheckCircle, XCircle, MousePointer2 } from "lucide-react";
|
import { Search, CheckCircle, XCircle, MousePointer2 } from "lucide-react";
|
||||||
|
import { getImageUrl } from "@/lib/api";
|
||||||
|
|
||||||
interface Hotspot {
|
interface Hotspot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,7 +91,7 @@ export default function HotspotPlayer({
|
|||||||
className="relative aspect-video rounded-3xl overflow-hidden border-4 border-white/10 bg-black cursor-crosshair group select-none shadow-2xl"
|
className="relative aspect-video rounded-3xl overflow-hidden border-4 border-white/10 bg-black cursor-crosshair group select-none shadow-2xl"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={getImageUrl(imageUrl)}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface MemoryCard {
|
|||||||
|
|
||||||
interface MemoryPlayerProps {
|
interface MemoryPlayerProps {
|
||||||
title: string;
|
title: string;
|
||||||
pairs: { id: string, content: string }[];
|
pairs: { left: string, right: string, id?: string }[];
|
||||||
onComplete: (score: number) => void;
|
onComplete: (score: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +30,10 @@ export default function MemoryPlayer({
|
|||||||
const initializeGame = useCallback(() => {
|
const initializeGame = useCallback(() => {
|
||||||
const gameCards: MemoryCard[] = [];
|
const gameCards: MemoryCard[] = [];
|
||||||
initialPairs.forEach((pair, idx) => {
|
initialPairs.forEach((pair, idx) => {
|
||||||
// Add two of each
|
const pairId = pair.id || idx.toString();
|
||||||
gameCards.push({ id: idx * 2, content: pair.content, pairId: pair.id, isFlipped: false, isMatched: false });
|
// Add two of each (Left and Right)
|
||||||
gameCards.push({ id: idx * 2 + 1, content: pair.content, pairId: pair.id, isFlipped: false, isMatched: false });
|
gameCards.push({ id: idx * 2, content: pair.left, pairId: pairId, isFlipped: false, isMatched: false });
|
||||||
|
gameCards.push({ id: idx * 2 + 1, content: pair.right, pairId: pairId, isFlipped: false, isMatched: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shuffle
|
// Shuffle
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ export interface Block {
|
|||||||
initialCode?: string;
|
initialCode?: string;
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
timeLimit?: number;
|
timeLimit?: number;
|
||||||
|
description?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
hotspots?: {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
|
|||||||
import DocumentBlock from "@/components/blocks/DocumentBlock";
|
import DocumentBlock from "@/components/blocks/DocumentBlock";
|
||||||
import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock";
|
import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock";
|
||||||
import AudioResponseBlock from "@/components/blocks/AudioResponseBlock";
|
import AudioResponseBlock from "@/components/blocks/AudioResponseBlock";
|
||||||
|
import HotspotBlock from "@/components/blocks/HotspotBlock";
|
||||||
|
import MemoryBlock from "@/components/blocks/MemoryBlock";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
X,
|
X,
|
||||||
@@ -42,6 +45,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
const [dueDate, setDueDate] = useState<string>("");
|
const [dueDate, setDueDate] = useState<string>("");
|
||||||
const [importantDateType, setImportantDateType] = useState<string>("");
|
const [importantDateType, setImportantDateType] = useState<string>("");
|
||||||
|
|
||||||
|
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
|
||||||
|
const [aiQuizContext, setAiQuizContext] = useState("");
|
||||||
|
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
|
||||||
|
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
|
|
||||||
@@ -156,7 +163,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response') => {
|
const addBlock = (type: Block['type']) => {
|
||||||
const newBlock: Block = {
|
const newBlock: Block = {
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
type,
|
type,
|
||||||
@@ -170,6 +177,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
...(type === 'document' && { url: "", title: "" }),
|
...(type === 'document' && { url: "", title: "" }),
|
||||||
...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }),
|
...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }),
|
||||||
...(type === 'audio-response' && { prompt: "Ask a question for the student to record their answer...", keywords: [], timeLimit: 60 }),
|
...(type === 'audio-response' && { prompt: "Ask a question for the student to record their answer...", keywords: [], timeLimit: 60 }),
|
||||||
|
...(type === 'hotspot' && { imageUrl: "", description: "Find the following items...", hotspots: [] }),
|
||||||
|
...(type === 'memory-match' && { pairs: [{ id: "1", left: "Term A", right: "Match A" }] }),
|
||||||
};
|
};
|
||||||
setBlocks([...blocks, newBlock]);
|
setBlocks([...blocks, newBlock]);
|
||||||
};
|
};
|
||||||
@@ -206,11 +215,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateQuiz = async () => {
|
const handleGenerateQuiz = async () => {
|
||||||
|
setIsAIQuizModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmGenerateQuiz = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
if (!lesson) return;
|
if (!lesson) return;
|
||||||
|
setIsAIQuizModalOpen(false);
|
||||||
setIsGeneratingQuiz(true);
|
setIsGeneratingQuiz(true);
|
||||||
try {
|
try {
|
||||||
const newBlocks = await cmsApi.generateQuiz(lesson.id);
|
const newBlocks = await cmsApi.generateQuiz(lesson.id, {
|
||||||
|
context: aiQuizContext,
|
||||||
|
quiz_type: aiQuizType
|
||||||
|
});
|
||||||
setBlocks([...blocks, ...newBlocks]);
|
setBlocks([...blocks, ...newBlocks]);
|
||||||
|
setAiQuizContext("");
|
||||||
} catch {
|
} catch {
|
||||||
alert("Failed to generate quiz.");
|
alert("Failed to generate quiz.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -598,6 +617,27 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
onChange={(updates) => updateBlock(block.id, updates)}
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{block.type === 'hotspot' && (
|
||||||
|
<HotspotBlock
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
description={block.description}
|
||||||
|
imageUrl={block.imageUrl}
|
||||||
|
hotspots={block.hotspots || []}
|
||||||
|
editMode={editMode}
|
||||||
|
courseId={params.id}
|
||||||
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{block.type === 'memory-match' && (
|
||||||
|
<MemoryBlock
|
||||||
|
id={block.id}
|
||||||
|
title={block.title}
|
||||||
|
pairs={block.pairs || []}
|
||||||
|
editMode={editMode}
|
||||||
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -677,6 +717,20 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<span className="text-2xl group-hover:scale-110 transition-transform">🎤</span>
|
<span className="text-2xl group-hover:scale-110 transition-transform">🎤</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Audio</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Audio</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addBlock('hotspot')}
|
||||||
|
className="flex flex-col items-center gap-2 p-6 glass hover:border-amber-500/50 transition-all group w-32"
|
||||||
|
>
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform">🔍</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Hotspot</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addBlock('memory-match')}
|
||||||
|
className="flex flex-col items-center gap-2 p-6 glass hover:border-purple-500/50 transition-all group w-32"
|
||||||
|
>
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform">🧠</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Memory</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-12 bg-white/5"></div>
|
<div className="w-px h-12 bg-white/5"></div>
|
||||||
|
|
||||||
@@ -693,6 +747,76 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isAIQuizModalOpen}
|
||||||
|
onClose={() => !isGeneratingQuiz && setIsAIQuizModalOpen(false)}
|
||||||
|
title="AI Quiz Customization"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleConfirmGenerateQuiz} className="space-y-6">
|
||||||
|
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/10">
|
||||||
|
<p className="text-xs text-purple-300 leading-relaxed font-medium">
|
||||||
|
Tell the AI what to focus on and what type of questions you prefer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">
|
||||||
|
Focus / Context
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={aiQuizContext}
|
||||||
|
onChange={(e) => setAiQuizContext(e.target.value)}
|
||||||
|
placeholder="e.g. Focus on past tense verbs, or use vocabulary related to travel..."
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-purple-500 transition-all text-white h-24 resize-none"
|
||||||
|
disabled={isGeneratingQuiz}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">
|
||||||
|
Question Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={aiQuizType}
|
||||||
|
onChange={(e) => setAiQuizType(e.target.value)}
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-purple-500 transition-all text-white appearance-none font-bold"
|
||||||
|
disabled={isGeneratingQuiz}
|
||||||
|
>
|
||||||
|
<option value="multiple-choice">Multiple Choice</option>
|
||||||
|
<option value="true-false">True / False</option>
|
||||||
|
<option value="vocabulary">Vocabulary Focus</option>
|
||||||
|
<option value="grammar">Grammar Focus</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAIQuizModalOpen(false)}
|
||||||
|
disabled={isGeneratingQuiz}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isGeneratingQuiz}
|
||||||
|
className="flex-[2] px-4 py-2.5 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg transition-all shadow-lg shadow-purple-500/20 font-bold text-sm flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isGeneratingQuiz ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Generate Quiz"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cmsApi, Course, Organization } from "@/lib/api";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useTranslation } from "@/context/I18nContext";
|
import { useTranslation } from "@/context/I18nContext";
|
||||||
import { Plus, BookOpen, Download, Upload, Sparkles, Wand2 } from "lucide-react";
|
import { Plus, BookOpen, Download, Upload, Sparkles, Wand2, Trash2 } from "lucide-react";
|
||||||
import OrganizationSelector from "@/components/OrganizationSelector";
|
import OrganizationSelector from "@/components/OrganizationSelector";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
@@ -140,6 +140,23 @@ export default function StudioDashboard() {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteCourse = async (e: React.MouseEvent, courseId: string, title: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!confirm(t('confirm_delete_course') || `Are you sure you want to delete "${title}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cmsApi.deleteCourse(courseId);
|
||||||
|
setCourses(prev => prev.filter(c => c.id !== courseId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete failed", err);
|
||||||
|
alert("Failed to delete course");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0f1115] text-white p-8">
|
<div className="min-h-screen bg-[#0f1115] text-white p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -201,6 +218,13 @@ export default function StudioDashboard() {
|
|||||||
>
|
>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteCourse(e, course.id, course.title)}
|
||||||
|
className="p-2 hover:bg-red-500/10 rounded-lg text-gray-500 hover:text-red-400 transition-all"
|
||||||
|
title="Delete Course"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
|
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
|
||||||
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
|
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { Bold, Italic, Link as LinkIcon, Image as ImageIcon, FileText, Eye, PenLine } from "lucide-react";
|
import { Bold, Italic, Link as LinkIcon, Image as ImageIcon, FileText, Eye, PenLine, Sparkles, Wand2, Check, X as CloseIcon } from "lucide-react";
|
||||||
import AssetPickerModal from "../AssetPickerModal";
|
import AssetPickerModal from "../AssetPickerModal";
|
||||||
import { Asset, getImageUrl } from "@/lib/api";
|
import { Asset, getImageUrl, cmsApi } from "@/lib/api";
|
||||||
|
|
||||||
interface DescriptionBlockProps {
|
interface DescriptionBlockProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,8 +19,30 @@ export default function DescriptionBlock({ id, title, content, editMode, courseI
|
|||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
||||||
const [pickerType, setPickerType] = useState<"image" | "file">("image");
|
const [pickerType, setPickerType] = useState<"image" | "file">("image");
|
||||||
|
const [isReviewing, setIsReviewing] = useState(false);
|
||||||
|
const [suggestion, setSuggestion] = useState<{ suggestion: string, comments: string } | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const handleReviewText = async () => {
|
||||||
|
if (!content.trim() || isReviewing) return;
|
||||||
|
setIsReviewing(true);
|
||||||
|
try {
|
||||||
|
const data = await cmsApi.reviewText(content);
|
||||||
|
setSuggestion(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Content review failed", err);
|
||||||
|
alert("Failed to review content");
|
||||||
|
} finally {
|
||||||
|
setIsReviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySuggestion = () => {
|
||||||
|
if (!suggestion) return;
|
||||||
|
onChange({ content: suggestion.suggestion });
|
||||||
|
setSuggestion(null);
|
||||||
|
};
|
||||||
|
|
||||||
const insertMarkdown = (prefix: string, suffix: string = "") => {
|
const insertMarkdown = (prefix: string, suffix: string = "") => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
@@ -99,6 +121,16 @@ export default function DescriptionBlock({ id, title, content, editMode, courseI
|
|||||||
>
|
>
|
||||||
<FileText size={14} />
|
<FileText size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="w-px h-3 bg-white/10 mx-1" />
|
||||||
|
<button
|
||||||
|
onClick={handleReviewText}
|
||||||
|
disabled={isReviewing}
|
||||||
|
className={`p-1.5 rounded-md transition-all flex items-center gap-1.5 text-xs font-bold ${isReviewing ? 'bg-indigo-500/20 text-indigo-300 animate-pulse' : 'hover:bg-indigo-500/20 text-indigo-400 hover:text-indigo-300'}`}
|
||||||
|
title="AI Suggest Improvements"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} className={isReviewing ? 'animate-spin' : ''} />
|
||||||
|
{isReviewing ? 'Analyzing...' : 'AI Suggest'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +170,54 @@ export default function DescriptionBlock({ id, title, content, editMode, courseI
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{suggestion && (
|
||||||
|
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-2xl p-6 space-y-4 animate-in fade-in slide-in-from-top-4 duration-500 shadow-xl shadow-indigo-500/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2 size={16} className="text-indigo-400" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-indigo-300">AI Teacher Suggestions</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSuggestion(null)}
|
||||||
|
className="p-1.5 hover:bg-white/10 rounded-lg text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
<CloseIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest pl-1">Improved Version</span>
|
||||||
|
<div className="p-4 bg-black/40 rounded-xl border border-white/5 text-sm text-gray-300 leading-relaxed italic">
|
||||||
|
{suggestion.suggestion}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest pl-1">Key Changes</span>
|
||||||
|
<div className="text-xs text-gray-400 leading-relaxed pl-1 whitespace-pre-line">
|
||||||
|
{suggestion.comments}
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={applySuggestion}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold rounded-lg transition-all shadow-lg shadow-indigo-500/20 flex items-center gap-2 group active:scale-95"
|
||||||
|
>
|
||||||
|
<Check size={14} className="group-hover:scale-110 transition-transform" />
|
||||||
|
Apply Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuggestion(null)}
|
||||||
|
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white text-xs font-bold rounded-lg border border-white/10 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="prose prose-invert max-w-none prose-p:text-gray-300 prose-p:leading-relaxed prose-p:text-lg prose-headings:text-white prose-a:text-blue-400 prose-img:rounded-xl">
|
<div className="prose prose-invert max-w-none prose-p:text-gray-300 prose-p:leading-relaxed prose-p:text-lg prose-headings:text-white prose-a:text-blue-400 prose-img:rounded-xl">
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair } from "lucide-react";
|
||||||
|
import AssetPickerModal from "../AssetPickerModal";
|
||||||
|
import { Asset, getImageUrl } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Hotspot {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HotspotBlockProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
hotspots?: Hotspot[];
|
||||||
|
editMode: boolean;
|
||||||
|
courseId: string;
|
||||||
|
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HotspotBlock({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
imageUrl,
|
||||||
|
hotspots = [],
|
||||||
|
editMode,
|
||||||
|
courseId,
|
||||||
|
onChange
|
||||||
|
}: HotspotBlockProps) {
|
||||||
|
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleImageSelect = (asset: Asset) => {
|
||||||
|
const url = asset.storage_path.replace('uploads/', '/assets/');
|
||||||
|
onChange({ imageUrl: url });
|
||||||
|
setIsAssetPickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageClick = (e: React.MouseEvent) => {
|
||||||
|
if (!editMode || !containerRef.current || !imageUrl) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
|
||||||
|
const newHotspot: Hotspot = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
radius: 5,
|
||||||
|
label: "New Hotspot"
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange({ hotspots: [...hotspots, newHotspot] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHotspot = (index: number, updates: Partial<Hotspot>) => {
|
||||||
|
const newHotspots = [...hotspots];
|
||||||
|
newHotspots[index] = { ...newHotspots[index], ...updates };
|
||||||
|
onChange({ hotspots: newHotspots });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHotspot = (index: number) => {
|
||||||
|
const newHotspots = hotspots.filter((_, i) => i !== index);
|
||||||
|
onChange({ hotspots: newHotspots });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editMode) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" id={id}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-amber-500/20 text-amber-500">
|
||||||
|
<Search size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white transition-colors">{title || "Image Hunt"}</h3>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-widest font-black">{description || "Find the hidden spots!"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative aspect-video rounded-2xl overflow-hidden border border-white/5 bg-black/40">
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image src={getImageUrl(imageUrl)} alt={title || ""} fill className="object-cover opacity-50" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-gray-600 italic text-sm">No image provided.</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center backdrop-blur-[2px]">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Crosshair className="w-12 h-12 text-white/20 mx-auto" />
|
||||||
|
<p className="text-xs font-bold text-white/40 uppercase tracking-widest">Interactive Game Preview (Switch to Student View to Play)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" id={id}>
|
||||||
|
<div className="p-6 glass border-white/5 bg-white/5 space-y-6 rounded-3xl">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Game Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title || ""}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
placeholder="e.g. Parts of the Body..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-sm font-bold focus:border-amber-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Student Instructions</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description || ""}
|
||||||
|
onChange={(e) => onChange({ description: e.target.value })}
|
||||||
|
placeholder="e.g. Find and click on the following items..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-sm focus:border-amber-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Game Image</label>
|
||||||
|
{!imageUrl ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAssetPickerOpen(true)}
|
||||||
|
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 hover:border-amber-500/50 hover:bg-amber-500/5 transition-all flex flex-col items-center justify-center gap-2 group"
|
||||||
|
>
|
||||||
|
<ImageIcon className="text-gray-600 group-hover:text-amber-500 transition-colors" size={32} />
|
||||||
|
<span className="text-xs font-bold text-gray-500 uppercase tracking-widest group-hover:text-amber-300">Choose Image</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="relative aspect-video rounded-2xl overflow-hidden group">
|
||||||
|
<Image src={getImageUrl(imageUrl)} alt="Hotspot base" fill className="object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
|
<button onClick={() => setIsAssetPickerOpen(true)} className="p-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-all"><ImageIcon size={18} /></button>
|
||||||
|
<button onClick={() => onChange({ imageUrl: undefined })} className="p-2 bg-red-500/20 hover:bg-red-500/40 rounded-lg text-red-400 transition-all"><Trash2 size={18} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="space-y-4 pt-4 border-t border-white/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Define Hotspots (Click on the image below)</label>
|
||||||
|
<span className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded uppercase tracking-widest">{hotspots.length} Defined</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-white/10 cursor-crosshair shadow-2xl"
|
||||||
|
>
|
||||||
|
<Image src={getImageUrl(imageUrl)} alt="Define Hotspots" fill className="object-cover select-none" />
|
||||||
|
{hotspots.map((h, idx) => (
|
||||||
|
<div
|
||||||
|
key={h.id}
|
||||||
|
className="absolute group/pin"
|
||||||
|
style={{
|
||||||
|
left: `${h.x}%`,
|
||||||
|
top: `${h.y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-amber-500/30 border-2 border-amber-400 rounded-full flex items-center justify-center relative transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
width: `${h.radius * 2}vw`,
|
||||||
|
height: `${h.radius * 2}vw`,
|
||||||
|
maxWidth: '100px',
|
||||||
|
maxHeight: '100px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-amber-500 rounded-full p-1 text-black shadow-lg">
|
||||||
|
<MapPin size={12} strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-[10px] font-bold text-white whitespace-nowrap opacity-0 group-hover/pin:opacity-100 transition-opacity">
|
||||||
|
{h.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 max-h-[400px] overflow-y-auto custom-scrollbar pr-2">
|
||||||
|
{hotspots.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center p-6 border-2 border-dashed border-white/5 rounded-2xl">
|
||||||
|
<Plus className="text-gray-700 mb-2" size={24} />
|
||||||
|
<p className="text-xs text-gray-600 font-bold uppercase tracking-widest">Click on the image to add hotspots</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hotspots.map((h, idx) => (
|
||||||
|
<div key={h.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-black text-amber-500 uppercase tracking-widest">Hotspot #{idx + 1}</span>
|
||||||
|
<button onClick={() => removeHotspot(idx)} className="p-1 hover:bg-red-500/20 text-red-500 rounded transition-colors"><Trash2 size={12} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={h.label}
|
||||||
|
onChange={(e) => updateHotspot(idx, { label: e.target.value })}
|
||||||
|
placeholder="Item name..."
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs font-bold focus:border-amber-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-gray-600">Radius</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="15"
|
||||||
|
value={h.radius}
|
||||||
|
onChange={(e) => updateHotspot(idx, { radius: parseInt(e.target.value) })}
|
||||||
|
className="flex-1 accent-amber-500 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-bold text-gray-400 w-6">{h.radius}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetPickerModal
|
||||||
|
isOpen={isAssetPickerOpen}
|
||||||
|
onClose={() => setIsAssetPickerOpen(false)}
|
||||||
|
courseId={courseId}
|
||||||
|
filterType="image"
|
||||||
|
onSelect={handleImageSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Brain, Plus, Trash2, HelpCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface MatchingPair {
|
||||||
|
left: string;
|
||||||
|
right: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryBlockProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
pairs?: MatchingPair[];
|
||||||
|
editMode: boolean;
|
||||||
|
onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MemoryBlock({ id, title, pairs = [], editMode, onChange }: MemoryBlockProps) {
|
||||||
|
const addPair = () => {
|
||||||
|
const newPair: MatchingPair = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
left: "",
|
||||||
|
right: ""
|
||||||
|
};
|
||||||
|
onChange({ pairs: [...pairs, newPair] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePair = (index: number, updates: Partial<MatchingPair>) => {
|
||||||
|
const newPairs = [...pairs];
|
||||||
|
newPairs[index] = { ...newPairs[index], ...updates };
|
||||||
|
onChange({ pairs: newPairs });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePair = (index: number) => {
|
||||||
|
const newPairs = pairs.filter((_, i) => i !== index);
|
||||||
|
onChange({ pairs: newPairs });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editMode) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" id={id}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white shadow-lg shadow-indigo-500/20">
|
||||||
|
<Brain size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-black tracking-tight text-white uppercase tracking-[0.1em]">{title || "Memory Match"}</h3>
|
||||||
|
<p className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Brain Training Exercise</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: Math.min(pairs.length * 2, 8) }).map((_, i) => (
|
||||||
|
<div key={i} className="h-24 rounded-2xl bg-white/5 border-2 border-dashed border-white/5 flex items-center justify-center">
|
||||||
|
<HelpCircle className="text-white/5" size={32} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-4 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
|
||||||
|
<p className="text-[10px] font-bold text-indigo-300 uppercase tracking-widest">Memory Game with {pairs.length} pairs defined</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" id={id}>
|
||||||
|
<div className="p-8 glass border-white/5 bg-white/5 space-y-8 rounded-3xl">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 pl-1">Game Description / Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title || ""}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
placeholder="e.g. Vocabulary Memory Match..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm font-bold focus:border-indigo-500/50 focus:outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between pl-1">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Pairs to Match</label>
|
||||||
|
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-2 py-1 rounded-md uppercase tracking-widest">{pairs.length} Pairs</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pairs.map((pair, idx) => (
|
||||||
|
<div key={pair.id || idx} className="grid grid-cols-1 sm:grid-cols-9 gap-4 p-4 bg-black/20 rounded-2xl border border-white/5 group animate-in slide-in-from-left-4 duration-300">
|
||||||
|
<div className="sm:col-span-4 space-y-1">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-tight text-gray-600 pl-1">Card A</span>
|
||||||
|
<input
|
||||||
|
value={pair.left}
|
||||||
|
onChange={(e) => updatePair(idx, { left: e.target.value })}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm font-bold focus:border-indigo-500/50 focus:outline-none transition-all"
|
||||||
|
placeholder="Term, Image URL, or Word..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-1 flex items-center justify-center pt-4 sm:pt-0">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
|
||||||
|
<div className="w-1 h-1 rounded-full bg-indigo-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3 space-y-1">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-tight text-gray-600 pl-1">Card B</span>
|
||||||
|
<input
|
||||||
|
value={pair.right}
|
||||||
|
onChange={(e) => updatePair(idx, { right: e.target.value })}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm font-bold focus:border-indigo-500/50 focus:outline-none transition-all"
|
||||||
|
placeholder="Matching Item..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-1 flex items-end justify-end pb-1 pr-1">
|
||||||
|
<button
|
||||||
|
onClick={() => removePair(idx)}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addPair}
|
||||||
|
className="w-full py-6 border-dashed border-2 border-white/5 text-gray-500 hover:text-indigo-400 hover:border-indigo-500/30 hover:bg-indigo-500/5 transition-all font-black text-[10px] uppercase tracking-widest rounded-3xl flex flex-col items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<div className="p-2 rounded-xl bg-white/5 group-hover:bg-indigo-500/20 transition-all">
|
||||||
|
<Plus size={20} />
|
||||||
|
</div>
|
||||||
|
<span>Add New Memory Pair</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,16 +44,17 @@ export interface QuizQuestion {
|
|||||||
|
|
||||||
export interface Block {
|
export interface Block {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match';
|
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot';
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
description?: string; // For hotspot or general info
|
||||||
media_type?: 'video' | 'audio';
|
media_type?: 'video' | 'audio';
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
quiz_data?: {
|
quiz_data?: {
|
||||||
questions: QuizQuestion[];
|
questions: QuizQuestion[];
|
||||||
};
|
};
|
||||||
pairs?: { left: string; right: string }[];
|
pairs?: { left: string; right: string; id?: string }[];
|
||||||
items?: string[];
|
items?: string[];
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
correctAnswers?: string[];
|
correctAnswers?: string[];
|
||||||
@@ -65,6 +66,14 @@ export interface Block {
|
|||||||
options: string[];
|
options: string[];
|
||||||
correctIndex: number;
|
correctIndex: number;
|
||||||
}[];
|
}[];
|
||||||
|
hotspots?: {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lesson {
|
export interface Lesson {
|
||||||
@@ -279,7 +288,8 @@ export const cmsApi = {
|
|||||||
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
|
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
|
||||||
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
|
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
|
||||||
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
|
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
|
||||||
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
|
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' }),
|
||||||
reorderModules: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/modules/reorder', { method: 'POST', body: JSON.stringify(payload) }),
|
reorderModules: (payload: { items: { id: string, position: number }[] }): Promise<void> => apiFetch('/modules/reorder', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
@@ -301,6 +311,8 @@ export const cmsApi = {
|
|||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
deleteCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
async generateCourse(prompt: string, targetOrgId?: string): Promise<Course> {
|
async generateCourse(prompt: string, targetOrgId?: string): Promise<Course> {
|
||||||
return apiFetch(`/courses/generate`, {
|
return apiFetch(`/courses/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user