diff --git a/backend/models/Course.go b/backend/models/Course.go
index 5f29447..e2e2fd5 100644
--- a/backend/models/Course.go
+++ b/backend/models/Course.go
@@ -1,30 +1,32 @@
package models
import (
- //"gorm.io/gorm"
"encoding/json"
"database/sql/driver"
)
type Course struct {
- ID uint `json:"id" gorm:"primaryKey"`
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Goto string `json:"goto"`
- Contents []Content `json:"contents" gorm:"foreignKey:CourseID"`
+ Contents []Content `json:"contents" gorm:"foreignKey:CourseID;constraint:OnDelete:CASCADE"`
}
type Content struct {
- ID uint `json:"id" gorm:"primaryKey"`
- CourseID uint `json:"-"`
+ ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
+ CourseID uint `json:"-" gorm:"not null"`
Title string `json:"title"`
Paragraph GormStrings `json:"paragraph" gorm:"type:jsonb"`
- Subcontent []Subcontent `json:"subcontent" gorm:"foreignKey:ContentID"`
+ Subcontent []Subcontent `json:"subcontent" gorm:"foreignKey:ContentID;constraint:OnDelete:CASCADE"`
Next string `json:"next,omitempty"`
+ MaxResourceConsumption int `json:"maxResourceConsumption"`
+ MaxProcessingTime int `json:"maxProcessingTime"`
}
+
type Subcontent struct {
- ID uint `json:"id" gorm:"primaryKey"`
- ContentID uint `json:"-"`
+ ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
+ ContentID uint `json:"-" gorm:"not null"`
Subtitle string `json:"subtitle"`
Subparagraph GormStrings `json:"subparagraph" gorm:"type:jsonb"`
Example GormStrings `json:"example" gorm:"type:jsonb"`
@@ -35,6 +37,7 @@ type GormStrings []string
func (g GormStrings) Value() (driver.Value, error) {
return json.Marshal(g)
}
+
func (g *GormStrings) Scan(value interface{}) error {
return json.Unmarshal(value.([]byte), g)
}
diff --git a/backend/routes/cors.go b/backend/routes/cors.go
index 905f72b..f683f74 100644
--- a/backend/routes/cors.go
+++ b/backend/routes/cors.go
@@ -56,5 +56,7 @@ func SetupRouter() *gin.Engine {
api.GET("/courses/:id", GetCourse)
api.POST("/courses", CreateCourse)
api.GET("/course/:goto", GetCourseIDByGoto)
+ api.PUT("/courses/:id", UpdateCourse)
+ api.DELETE("/courses/:id", DeleteCourse)
return r
}
diff --git a/backend/routes/courses.routes.go b/backend/routes/courses.routes.go
index 431baaf..0c8ca0e 100644
--- a/backend/routes/courses.routes.go
+++ b/backend/routes/courses.routes.go
@@ -25,17 +25,23 @@ type CourseInfo struct {
}
type ContentInfo struct {
- ID uint `json:"id"`
- Title string `json:"title"`
- Paragraph []string `json:"paragraph"`
- Subcontent []SubcontentInfo `json:"subcontent"`
- Next string `json:"next,omitempty"`
+ ID uint `json:"id"`
+ Title string `json:"title"`
+ Paragraph []string `json:"paragraph"`
+ Subcontent []SubcontentInfo `json:"subcontent"`
+ Next *string `json:"next"`
+ MaxResourceConsumption int `json:"maxResourceConsumption"`
+ MaxProcessingTime int `json:"maxProcessingTime"`
}
type SubcontentInfo struct {
- Subtitle string `json:"subtitle"`
- Subparagraph []string `json:"subparagraph"`
- Example []string `json:"example"`
+ Subtitle string `json:"subtitle"`
+ Subparagraph []string `json:"subparagraph"`
+ Example []ExampleInfo `json:"example"`
+}
+
+type ExampleInfo struct {
+ Code string `json:"code"`
}
type BasicCourseResponse struct {
@@ -45,6 +51,22 @@ type BasicCourseResponse struct {
Goto string `json:"goto"`
}
+// Helper function to convert *string to string
+func ptrStringToString(ptr *string) string {
+ if ptr == nil {
+ return ""
+ }
+ return *ptr
+}
+
+// Helper function to convert string to *string
+func stringToPtrString(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
func GetBasicCourses(c *gin.Context) {
var courses []models.Course
if err := db.DB.Select("id, title, description, goto").Find(&courses).Error; err != nil {
@@ -77,27 +99,41 @@ func CreateCourse(c *gin.Context) {
})
return
}
+
+ // Crear curso sin ID (para que GORM genere uno nuevo)
course := models.Course{
Title: courseRequest.Course.Title,
Description: courseRequest.Course.Description,
Goto: courseRequest.Course.Goto,
}
+
for _, contentInfo := range courseRequest.Contents {
+ // Crear contenido sin ID (para que GORM genere uno nuevo)
content := models.Content{
- Title: contentInfo.Title,
- Paragraph: models.GormStrings(contentInfo.Paragraph),
- Next: contentInfo.Next,
+ Title: contentInfo.Title,
+ Paragraph: models.GormStrings(contentInfo.Paragraph),
+ Next: ptrStringToString(contentInfo.Next),
+ MaxResourceConsumption: contentInfo.MaxResourceConsumption,
+ MaxProcessingTime: contentInfo.MaxProcessingTime,
}
+
for _, subcontentInfo := range contentInfo.Subcontent {
+ var exampleStrings []string
+ for _, example := range subcontentInfo.Example {
+ exampleStrings = append(exampleStrings, example.Code)
+ }
+
+ // Crear subcontenido sin ID (para que GORM genere uno nuevo)
subcontent := models.Subcontent{
Subtitle: subcontentInfo.Subtitle,
Subparagraph: models.GormStrings(subcontentInfo.Subparagraph),
- Example: models.GormStrings(subcontentInfo.Example),
+ Example: models.GormStrings(exampleStrings),
}
content.Subcontent = append(content.Subcontent, subcontent)
}
course.Contents = append(course.Contents, content)
}
+
if err := db.DB.Create(&course).Error; err != nil {
log.Printf("Error creating course: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
@@ -106,6 +142,7 @@ func CreateCourse(c *gin.Context) {
})
return
}
+
c.JSON(http.StatusCreated, gin.H{
"message": "Course created successfully",
"course_id": course.ID,
@@ -146,17 +183,24 @@ func GetCourse(c *gin.Context) {
}
for i, content := range course.Contents {
response.Contents[i] = ContentInfo{
- ID: content.ID,
- Title: content.Title,
- Paragraph: []string(content.Paragraph),
- Subcontent: make([]SubcontentInfo, len(content.Subcontent)),
- Next: content.Next,
+ ID: content.ID,
+ Title: content.Title,
+ Paragraph: []string(content.Paragraph),
+ Subcontent: make([]SubcontentInfo, len(content.Subcontent)),
+ Next: stringToPtrString(content.Next),
+ MaxResourceConsumption: content.MaxResourceConsumption,
+ MaxProcessingTime: content.MaxProcessingTime,
}
for j, subcontent := range content.Subcontent {
+ var examples []ExampleInfo
+ for _, exampleStr := range []string(subcontent.Example) {
+ examples = append(examples, ExampleInfo{Code: exampleStr})
+ }
+
response.Contents[i].Subcontent[j] = SubcontentInfo{
Subtitle: subcontent.Subtitle,
Subparagraph: []string(subcontent.Subparagraph),
- Example: []string(subcontent.Example),
+ Example: examples,
}
}
}
@@ -185,17 +229,24 @@ func GetAllCourses(c *gin.Context) {
}
for i, content := range course.Contents {
response.Contents[i] = ContentInfo{
- ID: content.ID,
- Title: content.Title,
- Paragraph: []string(content.Paragraph),
- Subcontent: make([]SubcontentInfo, len(content.Subcontent)),
- Next: content.Next,
+ ID: content.ID,
+ Title: content.Title,
+ Paragraph: []string(content.Paragraph),
+ Subcontent: make([]SubcontentInfo, len(content.Subcontent)),
+ Next: stringToPtrString(content.Next),
+ MaxResourceConsumption: content.MaxResourceConsumption,
+ MaxProcessingTime: content.MaxProcessingTime,
}
for j, subcontent := range content.Subcontent {
+ var examples []ExampleInfo
+ for _, exampleStr := range []string(subcontent.Example) {
+ examples = append(examples, ExampleInfo{Code: exampleStr})
+ }
+
response.Contents[i].Subcontent[j] = SubcontentInfo{
Subtitle: subcontent.Subtitle,
Subparagraph: []string(subcontent.Subparagraph),
- Example: []string(subcontent.Example),
+ Example: examples,
}
}
}
@@ -203,6 +254,227 @@ func GetAllCourses(c *gin.Context) {
}
c.JSON(http.StatusOK, responses)
}
+
+// FUNCIÓN UPDATECOURSE CORREGIDA
+func UpdateCourse(c *gin.Context) {
+ courseID := c.Param("id")
+ id, err := strconv.ParseUint(courseID, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid course ID",
+ })
+ return
+ }
+
+ var courseRequest CourseRequest
+ if err := c.ShouldBindJSON(&courseRequest); err != nil {
+ log.Printf("Error parsing JSON: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid JSON format",
+ "details": err.Error(),
+ })
+ return
+ }
+
+ // Verificar que el curso existe
+ var existingCourse models.Course
+ if err := db.DB.First(&existingCourse, uint(id)).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{
+ "error": "Course not found",
+ })
+ return
+ }
+ log.Printf("Error fetching course: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to fetch course",
+ })
+ return
+ }
+
+ // Usar transacción para asegurar consistencia
+ tx := db.DB.Begin()
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // Actualizar información básica del curso
+ existingCourse.Title = courseRequest.Course.Title
+ existingCourse.Description = courseRequest.Course.Description
+ existingCourse.Goto = courseRequest.Course.Goto
+
+ if err := tx.Save(&existingCourse).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error updating course: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to update course",
+ "details": err.Error(),
+ })
+ return
+ }
+
+ // PASO 1: Eliminar todos los subcontenidos existentes
+ if err := tx.Where("content_id IN (?)",
+ tx.Model(&models.Content{}).Select("id").Where("course_id = ?", existingCourse.ID),
+ ).Delete(&models.Subcontent{}).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error deleting subcontents: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete subcontents",
+ "details": err.Error(),
+ })
+ return
+ }
+
+ // PASO 2: Eliminar todos los contenidos existentes
+ if err := tx.Where("course_id = ?", existingCourse.ID).Delete(&models.Content{}).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error deleting contents: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete contents",
+ "details": err.Error(),
+ })
+ return
+ }
+
+ // PASO 3: Crear los nuevos contenidos (SIN especificar IDs)
+ for _, contentInfo := range courseRequest.Contents {
+ // IMPORTANTE: No establecer ID para que GORM genere uno nuevo
+ content := models.Content{
+ // ID se omite intencionalmente para que GORM genere uno nuevo
+ Title: contentInfo.Title,
+ Paragraph: models.GormStrings(contentInfo.Paragraph),
+ Next: ptrStringToString(contentInfo.Next),
+ MaxResourceConsumption: contentInfo.MaxResourceConsumption,
+ MaxProcessingTime: contentInfo.MaxProcessingTime,
+ CourseID: existingCourse.ID,
+ }
+
+ for _, subcontentInfo := range contentInfo.Subcontent {
+ var exampleStrings []string
+ for _, example := range subcontentInfo.Example {
+ exampleStrings = append(exampleStrings, example.Code)
+ }
+
+ // IMPORTANTE: No establecer ID para que GORM genere uno nuevo
+ subcontent := models.Subcontent{
+ // ID se omite intencionalmente para que GORM genere uno nuevo
+ // ContentID se establecerá automáticamente por GORM
+ Subtitle: subcontentInfo.Subtitle,
+ Subparagraph: models.GormStrings(subcontentInfo.Subparagraph),
+ Example: models.GormStrings(exampleStrings),
+ }
+ content.Subcontent = append(content.Subcontent, subcontent)
+ }
+
+ if err := tx.Create(&content).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error creating content: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to create course content",
+ "details": err.Error(),
+ })
+ return
+ }
+ }
+
+ // Confirmar transacción
+ if err := tx.Commit().Error; err != nil {
+ log.Printf("Error committing transaction: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to update course",
+ "details": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Course updated successfully",
+ "course_id": existingCourse.ID,
+ })
+}
+
+func DeleteCourse(c *gin.Context) {
+ courseID := c.Param("id")
+ id, err := strconv.ParseUint(courseID, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid course ID",
+ })
+ return
+ }
+
+ // Verificar que el curso existe
+ var course models.Course
+ if err := db.DB.First(&course, uint(id)).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{
+ "error": "Course not found",
+ })
+ return
+ }
+ log.Printf("Error fetching course: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to fetch course",
+ })
+ return
+ }
+
+ // Usar transacción para eliminar en cascada
+ tx := db.DB.Begin()
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // Eliminar subcontenidos
+ if err := tx.Where("content_id IN (?)",
+ tx.Model(&models.Content{}).Select("id").Where("course_id = ?", course.ID),
+ ).Delete(&models.Subcontent{}).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error deleting subcontents: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete subcontents",
+ })
+ return
+ }
+
+ // Eliminar contenidos
+ if err := tx.Where("course_id = ?", course.ID).Delete(&models.Content{}).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error deleting contents: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete contents",
+ })
+ return
+ }
+
+ // Eliminar curso
+ if err := tx.Delete(&course).Error; err != nil {
+ tx.Rollback()
+ log.Printf("Error deleting course: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete course",
+ })
+ return
+ }
+
+ if err := tx.Commit().Error; err != nil {
+ log.Printf("Error committing delete transaction: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete course",
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Course deleted successfully",
+ })
+}
+
func GetCourseIDByGoto(c *gin.Context) {
gotoParam := c.Param("goto")
diff --git a/frontend/angular/public/i18n/en.json b/frontend/angular/public/i18n/en.json
index e43a1ce..96a3a5d 100644
--- a/frontend/angular/public/i18n/en.json
+++ b/frontend/angular/public/i18n/en.json
@@ -8,6 +8,7 @@
"terminal": "Terminal",
"chat": "Chat",
"myLearning": "My Learning",
+ "createCourse": "Create Course",
"language": "Language"
},
"HOME": {
diff --git a/frontend/angular/public/i18n/es.json b/frontend/angular/public/i18n/es.json
index 0826429..cd73ec7 100644
--- a/frontend/angular/public/i18n/es.json
+++ b/frontend/angular/public/i18n/es.json
@@ -8,6 +8,7 @@
"terminal": "Terminal",
"chat": "Chat",
"myLearning": "Mi aprendizaje",
+ "createCourse": "Crear curso",
"language": "Idioma"
},
"HOME": {
diff --git a/frontend/angular/public/i18n/qu.json b/frontend/angular/public/i18n/qu.json
index 3496df3..e8b6592 100644
--- a/frontend/angular/public/i18n/qu.json
+++ b/frontend/angular/public/i18n/qu.json
@@ -8,6 +8,7 @@
"terminal": "Terminal",
"chat": "Rimanakuy",
"myLearning": "Ñuqa yachachiyki",
+ "createCourse": "Cursota Paqarichiy",
"language": "Rimay"
},
"HOME": {
diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts
index a680a0f..1e7dadc 100644
--- a/frontend/angular/src/app/app.routes.ts
+++ b/frontend/angular/src/app/app.routes.ts
@@ -13,6 +13,7 @@ import { CursosComponent } from './pages/cursos/cursos.component';
import { IntroductionComponent } from './pages/introduction/introduction.component';
import { ChatComponent } from './pages/chat/chat.component';
import { CreateCourseComponent } from './pages/create-course/create-course.component';
+import { EditCourseComponent } from './pages/edit-course/edit-course.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
@@ -29,6 +30,7 @@ export const routes: Routes = [
{ path: 'lista-cursos', component: CoursesListComponent },
{ path: 'terminal', component: TerminalComponent },
{ path: 'cursos/:id', component: IntroductionComponent },
+ { path: 'editar-curso/:id', component: EditCourseComponent },
{ path: 'chat', component: ChatComponent },
{ path: 'crear-curso', component: CreateCourseComponent },
diff --git a/frontend/angular/src/app/components/card-curso/card-curso.component.html b/frontend/angular/src/app/components/card-curso/card-curso.component.html
index 44484e5..cbd905e 100644
--- a/frontend/angular/src/app/components/card-curso/card-curso.component.html
+++ b/frontend/angular/src/app/components/card-curso/card-curso.component.html
@@ -1,4 +1,16 @@
-
{{ content.title }}
+
+
{{ content.description }}
diff --git a/frontend/angular/src/app/components/card-curso/card-curso.component.scss b/frontend/angular/src/app/components/card-curso/card-curso.component.scss
index 8270932..75e4421 100644
--- a/frontend/angular/src/app/components/card-curso/card-curso.component.scss
+++ b/frontend/angular/src/app/components/card-curso/card-curso.component.scss
@@ -1,25 +1,69 @@
+@use "sass:color"; // solo necesario si usás funciones como color.adjust
+
+// Variables locales de color (copiadas de tu diseño original)
+$primary-color: #4346ff;
+$secondary-color: #ff6a81;
+$background-dark: #000000;
+$background-light: #272149;
+$text-light: #ffffff;
+$text-muted: rgba(255, 255, 255, 0.8);
+$card-bg: rgba(255, 255, 255, 0.05);
+$card-border: rgba(255, 255, 255, 0.1);
+$error-color: #f44336;
+
.card {
- background-color: #222;
+ background-color: $card-bg;
+ border: 1px solid $card-border;
border-radius: 12px;
- color: #ccc;
- padding: 1rem;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- height: 100%;
- width: 100%;
- box-shadow: 0 4px 6px rgba(0,0,0,0.2);
-}
+ padding: 20px;
+ position: relative;
+ transition: all 0.3s ease;
+ color: $text-light;
-.card-title {
- font-size: 1.2rem;
- margin: 0;
-}
+ &:hover {
+ background-color: rgba($primary-color, 0.05);
+ }
+
+ .card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
-.card-description {
- font-size: 0.9rem;
- margin: 0;
+ .card-title {
+ font-size: 1.5rem;
+ margin: 0;
+ background: linear-gradient(45deg, $primary-color, $secondary-color);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ }
+
+ .card-description {
+ margin-top: 12px;
+ font-size: 1rem;
+ color: $text-muted;
+ }
+
+ .edit-button {
+ background: transparent;
+ border: none;
+ color: $primary-color;
+ font-size: 1.2rem;
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 50%;
+ transition: background 0.3s;
+
+ &:hover {
+ background: rgba($primary-color, 0.15);
+ }
+
+ i {
+ pointer-events: none;
+ }
+ }
}
+
//poner buenos estilos :v
\ No newline at end of file
diff --git a/frontend/angular/src/app/components/card-curso/card-curso.component.ts b/frontend/angular/src/app/components/card-curso/card-curso.component.ts
index 70c5031..69581f7 100644
--- a/frontend/angular/src/app/components/card-curso/card-curso.component.ts
+++ b/frontend/angular/src/app/components/card-curso/card-curso.component.ts
@@ -1,16 +1,24 @@
import { Component, Input } from '@angular/core';
+import { Router } from '@angular/router';
+import { ICardCurso } from '../../shared/interfaces/interfaces';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
-import { ICardCurso } from '../../shared/interfaces/interfaces';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-card-curso',
+ standalone: true,
imports: [MatCardModule, MatButtonModule, RouterModule],
templateUrl: './card-curso.component.html',
- standalone: true,
styleUrl: './card-curso.component.scss',
})
export class CardCursoComponent {
@Input() content!: ICardCurso;
+
+ constructor(private router: Router) {}
+
+ onEdit(event: MouseEvent) {
+ event.stopPropagation(); // Evita que también se dispare el routerLink general
+ this.router.navigate(['/editar-curso', this.content.id]);
+ }
}
diff --git a/frontend/angular/src/app/components/form-course/form-course.component.html b/frontend/angular/src/app/components/form-course/form-course.component.html
new file mode 100644
index 0000000..02fdb6a
--- /dev/null
+++ b/frontend/angular/src/app/components/form-course/form-course.component.html
@@ -0,0 +1,238 @@
+
\ No newline at end of file
diff --git a/frontend/angular/src/app/components/form-course/form-course.component.scss b/frontend/angular/src/app/components/form-course/form-course.component.scss
new file mode 100644
index 0000000..99deedb
--- /dev/null
+++ b/frontend/angular/src/app/components/form-course/form-course.component.scss
@@ -0,0 +1,511 @@
+@use "sass:color";
+
+// Variables de color
+$primary-color: #4346ff;
+$secondary-color: #ff6a81;
+$background-dark: #000000;
+$background-light: #272149;
+$text-light: #ffffff;
+$text-muted: rgba(255, 255, 255, 0.8);
+$card-bg: rgba(255, 255, 255, 0.05);
+$card-border: rgba(255, 255, 255, 0.1);
+$error-color: #f44336;
+$success-color: #4caf50;
+
+.course-form {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 120px;
+
+ @media (max-width: 768px) {
+ padding: 0 20px;
+ }
+}
+
+.form-section {
+ background: $card-bg;
+ border: 1px solid $card-border;
+ border-radius: 12px;
+ padding: 30px;
+ margin-bottom: 30px;
+ backdrop-filter: blur(10px);
+
+ h2 {
+ font-size: 1.5rem;
+ margin-bottom: 25px;
+ font-weight: 600;
+ position: relative;
+ color: $text-light;
+
+ &:after {
+ content: "";
+ position: absolute;
+ bottom: -8px;
+ left: 0;
+ width: 60px;
+ height: 3px;
+ background: linear-gradient(90deg, $primary-color, $secondary-color);
+ border-radius: 2px;
+ }
+ }
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 25px;
+
+ @media (max-width: 576px) {
+ flex-direction: column;
+ gap: 15px;
+ align-items: stretch;
+ }
+}
+
+.form-group {
+ margin-bottom: 20px;
+
+ label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: $text-light;
+ font-size: 0.95rem;
+ }
+}
+
+.form-input,
+.form-textarea,
+.form-select {
+ width: 100%;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid $card-border;
+ border-radius: 8px;
+ color: $text-light;
+ font-size: 0.95rem;
+ transition: all 0.3s ease;
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: $secondary-color;
+ box-shadow: 0 0 0 3px rgba(138, 99, 210, 0.1);
+ background: rgba(255, 255, 255, 0.15);
+ }
+
+ &:invalid {
+ border-color: $error-color;
+ }
+}
+
+.form-textarea {
+ resize: vertical;
+ min-height: 60px;
+}
+
+.code-textarea {
+ width: 100%;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.4);
+ border: 1px solid $card-border;
+ border-radius: 8px;
+ color: $text-light;
+ font-family: "Courier New", monospace;
+ font-size: 0.9rem;
+ line-height: 1.4;
+ resize: vertical;
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: $primary-color;
+ box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1);
+ }
+}
+
+.error-message {
+ color: $error-color;
+ font-size: 0.85rem;
+ margin-top: 5px;
+ display: block;
+}
+
+// Botones
+.add-button,
+.add-sub-button,
+.add-example-button {
+ background: linear-gradient(45deg, $primary-color, $secondary-color);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ &:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(223, 136, 29, 0.3);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+.add-sub-button,
+.add-example-button {
+ font-size: 0.85rem;
+ padding: 8px 16px;
+}
+
+.remove-button,
+.remove-sub-button,
+.remove-example-button {
+ background: rgba(255, 82, 82, 0.1);
+ color: $error-color;
+ border: 1px solid rgba(244, 67, 54, 0.5);
+ padding: 8px 14px;
+ border-radius: 6px;
+ font-weight: 500;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ &:hover {
+ background: rgba(244, 67, 54, 0.2);
+ border-color: rgba(244, 67, 54, 0.7);
+ transform: scale(1.05);
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.2);
+ }
+}
+
+// Contenedores de contenido con jerarquía visual mejorada
+.contents-container {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+}
+
+.content-card {
+ background: rgba(255, 255, 255, 0.08);
+ border: 2px solid rgba(138, 99, 210, 0.3);
+ border-left: 4px solid $secondary-color;
+ border-radius: 10px;
+ margin-bottom: 2rem;
+ position: relative;
+ backdrop-filter: blur(10px);
+}
+
+.content-header {
+ background: rgba(138, 99, 210, 0.15);
+ padding: 1rem;
+ border-bottom: 1px solid rgba(138, 99, 210, 0.2);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0;
+
+ h3 {
+ color: $text-light;
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin: 0;
+ }
+}
+
+.content-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.content-number {
+ background: $secondary-color;
+ color: white;
+ padding: 0.25rem 0.5rem;
+ border-radius: 50%;
+ font-weight: bold;
+ font-size: 0.875rem;
+ min-width: 2rem;
+ text-align: center;
+}
+
+.content-body {
+ padding: 1.5rem;
+}
+
+.subcontent-section {
+ margin-top: 2rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ padding-top: 1.5rem;
+
+ .subcontent-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+
+ h4 {
+ color: $primary-color;
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin: 0;
+ }
+ }
+}
+
+.subcontent-container {
+ margin-left: 1rem;
+ border-left: 2px solid rgba(255, 255, 255, 0.1);
+ padding-left: 1rem;
+}
+
+.subcontent-card {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ margin-bottom: 1rem;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+.subcontent-header-item {
+ background: rgba(255, 255, 255, 0.08);
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.subcontent-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.subcontent-number {
+ background: $primary-color;
+ color: white;
+ padding: 0.2rem 0.4rem;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 0.75rem;
+}
+
+.subcontent-body {
+ padding: 1rem;
+}
+
+.examples-section {
+ margin-top: 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ padding-top: 1rem;
+
+ .examples-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+
+ h5 {
+ color: rgba(76, 175, 80, 0.9);
+ margin: 0;
+ font-size: 1rem;
+ }
+ }
+}
+
+.examples-container {
+ margin-left: 1rem;
+ border-left: 2px solid rgba(255, 255, 255, 0.05);
+ padding-left: 1rem;
+}
+
+.example-item {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ margin-bottom: 0.75rem;
+ overflow: hidden;
+ position: relative;
+}
+
+.example-header {
+ background: rgba(76, 175, 80, 0.15);
+ padding: 0.5rem 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border-bottom: 1px solid rgba(76, 175, 80, 0.2);
+}
+
+.example-number {
+ background: $success-color;
+ color: white;
+ padding: 0.15rem 0.3rem;
+ border-radius: 3px;
+ font-weight: bold;
+ font-size: 0.7rem;
+}
+
+.example-label {
+ font-size: 0.875rem;
+ color: rgba(76, 175, 80, 0.9);
+ font-weight: 500;
+}
+
+// Sobrescribimos el code-textarea dentro de examples para mantener coherencia
+.example-item .code-textarea {
+ width: 100%;
+ border: none;
+ background: rgba(0, 0, 0, 0.6);
+ font-family: "Courier New", monospace;
+ padding: 0.75rem;
+ resize: vertical;
+ min-height: 120px;
+ color: $text-light;
+ border-radius: 0;
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: $primary-color;
+ box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1);
+ }
+}
+
+.remove-example-button {
+ margin-left: auto;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+}
+
+// Acciones del formulario
+.form-actions {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ margin-top: 40px;
+ padding: 0 120px;
+
+ @media (max-width: 768px) {
+ padding: 0 20px;
+ flex-direction: column;
+ }
+}
+
+.cancel-button {
+ background: transparent;
+ color: $text-muted;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 14px 32px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: $text-light;
+ }
+}
+
+.submit-button {
+ background: linear-gradient(45deg, $primary-color, $secondary-color);
+ color: white;
+ border: none;
+ padding: 14px 32px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ min-width: 150px;
+
+ &:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(223, 136, 29, 0.4);
+ }
+
+ &:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+ transform: none;
+ }
+
+ .fa-spinner {
+ margin-right: 8px;
+ }
+}
+
+// Vista previa
+.preview-section {
+ max-width: 1200px;
+ margin: 40px auto 0;
+ padding: 0 120px;
+
+ @media (max-width: 768px) {
+ padding: 0 20px;
+ }
+
+ h3 {
+ color: $text-light;
+ margin-bottom: 15px;
+ font-size: 1.2rem;
+ }
+
+ .json-preview {
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid $card-border;
+ border-radius: 8px;
+ padding: 20px;
+ color: $text-light;
+ font-family: "Courier New", monospace;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ max-height: 400px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ }
+}
+
+// Responsive
+@media (max-width: 576px) {
+ .form-actions {
+ flex-direction: column;
+
+ .cancel-button,
+ .submit-button {
+ width: 100%;
+ }
+ }
+
+ .section-header {
+ .add-button {
+ width: 100%;
+ justify-content: center;
+ }
+ }
+}
diff --git a/frontend/angular/src/app/components/form-course/form-course.component.spec.ts b/frontend/angular/src/app/components/form-course/form-course.component.spec.ts
new file mode 100644
index 0000000..62019c7
--- /dev/null
+++ b/frontend/angular/src/app/components/form-course/form-course.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { FormCourseComponent } from './form-course.component';
+
+describe('FormCourseComponent', () => {
+ let component: FormCourseComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ReactiveFormsModule,FormCourseComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(FormCourseComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/frontend/angular/src/app/components/form-course/form-course.component.ts b/frontend/angular/src/app/components/form-course/form-course.component.ts
new file mode 100644
index 0000000..bad4596
--- /dev/null
+++ b/frontend/angular/src/app/components/form-course/form-course.component.ts
@@ -0,0 +1,414 @@
+import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
+import { FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+import { ReactiveFormsModule } from '@angular/forms';
+import { AbstractControl, ValidationErrors } from '@angular/forms';
+
+
+interface CourseData {
+ course: {
+ title: string;
+ description: string;
+ goto: string;
+ };
+ contents: ContentData[];
+}
+
+interface ContentData {
+ title: string;
+ paragraph: string[];
+ subcontent: SubcontentData[];
+ next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
+}
+
+interface SubcontentData {
+ subtitle: string;
+ subparagraph: string[];
+ example: ExampleData[];
+}
+
+interface ExampleData {
+ code: string;
+}
+
+interface FormContentValue {
+ title: string;
+ paragraph: string;
+ subcontent: FormSubcontentValue[];
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
+}
+
+interface FormSubcontentValue {
+ subtitle: string;
+ subparagraph: string;
+ example: FormExampleValue[];
+}
+
+interface FormExampleValue {
+ code: string;
+}
+
+interface FormValue {
+ title: string;
+ description: string;
+ contents: FormContentValue[];
+}
+
+// Custom validators
+function positiveIntegerValidator(control: AbstractControl): ValidationErrors | null {
+ const value = control.value;
+ if (value === null || value === undefined || value === '') {
+ return null; // Let required validator handle empty values
+ }
+
+ const numValue = Number(value);
+ if (!Number.isInteger(numValue) || numValue <= 0) {
+ return { positiveInteger: true };
+ }
+
+ return null;
+}
+
+
+@Component({
+ selector: 'app-form-course',
+ standalone: true,
+ templateUrl: './form-course.component.html',
+ styleUrls: ['./form-course.component.scss'],
+ imports: [CommonModule, ReactiveFormsModule]
+})
+export class FormCourseComponent implements OnInit, OnChanges {
+ @Input() initialData?: CourseData; // Para modo edición
+ @Input() showPreview = false;
+ @Output() formDataChange = new EventEmitter();
+ @Output() formValidChange = new EventEmitter();
+
+ courseForm: FormGroup;
+
+ constructor(private fb: FormBuilder) {
+ this.courseForm = this.createCourseForm();
+ }
+
+ ngOnInit(): void {
+ // Agregar el primer contenido por defecto si no hay datos iniciales
+ if (this.contents.length === 0 && !this.initialData) {
+ this.addContent();
+ }
+
+ // Suscribirse a cambios del formulario
+ this.courseForm.valueChanges.subscribe(() => {
+ this.emitFormData();
+ });
+
+ this.courseForm.statusChanges.subscribe(() => {
+ this.emitFormValid();
+ });
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['initialData'] && changes['initialData'].currentValue) {
+ this.loadInitialData(changes['initialData'].currentValue);
+ }
+ }
+
+ private createCourseForm(): FormGroup {
+ return this.fb.group({
+ title: ['', [Validators.required, Validators.minLength(3)]],
+ description: ['', [Validators.required, Validators.minLength(10)]],
+ contents: this.fb.array([])
+ });
+ }
+
+ private createContentFormGroup(content?: ContentData): FormGroup {
+ return this.fb.group({
+ title: [content?.title || '', Validators.required],
+ paragraph: [content?.paragraph?.join('\n') || '', Validators.required],
+ maxResourceConsumption: [
+ content?.maxResourceConsumption || '',
+ [Validators.required, positiveIntegerValidator]
+ ],
+ maxProcessingTime: [
+ content?.maxProcessingTime || '',
+ [Validators.required, positiveIntegerValidator]
+ ],
+ subcontent: this.fb.array([])
+ });
+ }
+
+ private createExampleFormGroup(example?: ExampleData): FormGroup {
+ return this.fb.group({
+ code: [example?.code || '', Validators.required]
+ });
+ }
+
+ private loadInitialData(data: CourseData): void {
+ // Limpiar contenidos existentes
+ this.contents.clear();
+
+ // Cargar datos del curso
+ this.courseForm.patchValue({
+ title: data.course.title,
+ description: data.course.description
+ });
+
+ // Cargar contenidos
+ data.contents.forEach(content => {
+ const contentGroup = this.createContentFormGroup(content);
+
+ // Cargar subcontenidos
+ content.subcontent.forEach(subcontent => {
+ const subcontentGroup = this.fb.group({
+ subtitle: [subcontent.subtitle, Validators.required],
+ subparagraph: [subcontent.subparagraph.join('\n'), Validators.required],
+ example: this.fb.array(
+ subcontent.example.map(example => this.createExampleFormGroup(example))
+ )
+ });
+
+ (contentGroup.get('subcontent') as FormArray).push(subcontentGroup);
+ });
+
+ this.contents.push(contentGroup);
+ });
+
+ this.emitFormData();
+ this.emitFormValid();
+ }
+
+ // Getters para FormArrays
+ get contents(): FormArray {
+ return this.courseForm.get('contents') as FormArray;
+ }
+
+ getSubcontents(contentIndex: number): FormArray {
+ return this.contents.at(contentIndex).get('subcontent') as FormArray;
+ }
+
+ getExamples(contentIndex: number, subcontentIndex: number): FormArray {
+ return this.getSubcontents(contentIndex).at(subcontentIndex).get('example') as FormArray;
+ }
+
+ // Métodos para manejar contenidos
+ addContent(): void {
+ if (this.contents.length < 20) {
+ const contentGroup = this.createContentFormGroup();
+ this.contents.push(contentGroup);
+
+ // Agregar el primer subcontenido automáticamente
+ this.addSubcontent(this.contents.length - 1);
+
+ // Actualizar las referencias "next" automáticamente
+ this.updateNextOptions();
+ }
+ }
+
+ removeContent(index: number): void {
+ if (this.contents.length > 1) {
+ this.contents.removeAt(index);
+ this.updateNextOptions();
+ }
+ }
+
+ // Métodos para manejar subcontenidos
+ addSubcontent(contentIndex: number): void {
+ const subcontents = this.getSubcontents(contentIndex);
+ if (subcontents.length < 10) {
+ const subcontentGroup = this.fb.group({
+ subtitle: ['', Validators.required],
+ subparagraph: ['', Validators.required],
+ example: this.fb.array([this.createExampleFormGroup()])
+ });
+
+ subcontents.push(subcontentGroup);
+ }
+ }
+
+ removeSubcontent(contentIndex: number, subcontentIndex: number): void {
+ const subcontents = this.getSubcontents(contentIndex);
+ if (subcontents.length > 1) {
+ subcontents.removeAt(subcontentIndex);
+ }
+ }
+
+ // Métodos para manejar ejemplos
+ addExample(contentIndex: number, subcontentIndex: number): void {
+ const examples = this.getExamples(contentIndex, subcontentIndex);
+ if (examples.length < 5) {
+ examples.push(this.createExampleFormGroup());
+ }
+ }
+
+ removeExample(contentIndex: number, subcontentIndex: number, exampleIndex: number): void {
+ const examples = this.getExamples(contentIndex, subcontentIndex);
+ if (examples.length > 1) {
+ examples.removeAt(exampleIndex);
+ }
+ }
+
+ // Actualizar opciones de "siguiente contenido" automáticamente
+ updateNextOptions(): void {
+ // Esta función ahora solo actualiza los valores "next" internamente
+ // No se muestra en el formulario, pero se usa en el JSON final
+ }
+
+ // Generar ruta de acceso automáticamente desde el título
+ private generateGoto(title: string): string {
+ return title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, '') // Eliminar caracteres especiales
+ .replace(/\s+/g, '-') // Reemplazar espacios con guiones
+ .trim();
+ }
+
+ // Formatear datos para el JSON final
+ private formatCourseData(): CourseData {
+ const formValue = this.courseForm.value as FormValue;
+
+ const contents: ContentData[] = formValue.contents.map((content: FormContentValue, index: number) => {
+ const contentData: ContentData = {
+ title: content.title,
+ paragraph: [content.paragraph],
+ subcontent: content.subcontent.map((sub: FormSubcontentValue) => ({
+ subtitle: sub.subtitle,
+ subparagraph: [sub.subparagraph],
+ example: sub.example
+ .filter((ex: FormExampleValue) => ex.code.trim() !== '')
+ .map((ex: FormExampleValue) => ({
+ code: ex.code
+ }))
+ })),
+ next: null, // Inicializar next como null por defecto
+ maxResourceConsumption: Number(content.maxResourceConsumption),
+ maxProcessingTime: Number(content.maxProcessingTime)
+ };
+
+ // Agregar "next" automáticamente si no es el último contenido
+ if (index < formValue.contents.length - 1) {
+ const nextContent = formValue.contents[index + 1];
+ // Verificar que el siguiente contenido existe y tiene título
+ if (nextContent && nextContent.title && nextContent.title.trim() !== '') {
+ contentData.next = nextContent.title.trim();
+ }
+ }
+
+ return contentData;
+ });
+
+ return {
+ course: {
+ title: formValue.title,
+ description: formValue.description,
+ goto: this.generateGoto(formValue.title)
+ },
+ contents
+ };
+ }
+
+ // Vista previa del JSON
+ getPreviewJson(): string {
+ if (this.courseForm.valid) {
+ return JSON.stringify(this.formatCourseData(), null, 2);
+ }
+ return 'Completa todos los campos requeridos para ver la vista previa';
+ }
+
+ // Validación personalizada
+ validateCourse(): boolean {
+ // Validar que cada contenido tenga al menos un subcontenido
+ for (let i = 0; i < this.contents.length; i++) {
+ const subcontents = this.getSubcontents(i);
+ if (subcontents.length === 0) {
+ alert(`El contenido ${i + 1} debe tener al menos un subcontenido`);
+ return false;
+ }
+
+ // Validar que cada subcontenido tenga al menos un ejemplo
+ for (let j = 0; j < subcontents.length; j++) {
+ const examples = this.getExamples(i, j);
+ const validExamples = examples.controls.filter(ex =>
+ ex.get('code')?.value.trim() !== ''
+ );
+ if (validExamples.length === 0) {
+ alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo válido`);
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ // Marcar todos los campos como tocados para mostrar errores
+ markFormGroupTouched(): void {
+ this.markFormGroupTouchedRecursive(this.courseForm);
+ }
+
+ private markFormGroupTouchedRecursive(formGroup: FormGroup | FormArray): void {
+ Object.keys(formGroup.controls).forEach(key => {
+ const control = formGroup.get(key);
+ if (control instanceof FormGroup || control instanceof FormArray) {
+ this.markFormGroupTouchedRecursive(control);
+ } else {
+ control?.markAsTouched();
+ }
+ });
+ }
+
+ // Método para actualizar ruta automáticamente cuando cambia el título
+ onTitleChange(): void {
+ // Este método se mantiene para compatibilidad con el template
+ // La generación de goto ahora es automática en formatCourseData()
+ }
+
+ // Métodos para emitir cambios al componente padre
+ private emitFormData(): void {
+ if (this.courseForm.valid) {
+ this.formDataChange.emit(this.formatCourseData());
+ }
+ }
+
+ private emitFormValid(): void {
+ this.formValidChange.emit(this.courseForm.valid && this.validateCourse());
+ }
+
+ // Método para obtener los datos del formulario (para uso externo)
+ getFormData(): CourseData | null {
+ if (this.courseForm.valid && this.validateCourse()) {
+ return this.formatCourseData();
+ }
+ return null;
+ }
+
+ // Método para resetear el formulario
+ resetForm(): void {
+ this.courseForm.reset();
+ this.contents.clear();
+ this.addContent();
+ }
+
+ // Métodos auxiliares para obtener mensajes de error
+ getResourceConsumptionError(contentIndex: number): string {
+ const control = this.contents.at(contentIndex).get('maxResourceConsumption');
+ if (control?.hasError('required')) {
+ return 'El consumo máximo de recursos es requerido';
+ }
+ if (control?.hasError('positiveInteger')) {
+ return 'Debe ser un número entero positivo mayor a 0';
+ }
+ return '';
+ }
+
+ getProcessingTimeError(contentIndex: number): string {
+ const control = this.contents.at(contentIndex).get('maxProcessingTime');
+ if (control?.hasError('required')) {
+ return 'El tiempo máximo de procesamiento es requerido';
+ }
+ if (control?.hasError('positiveInteger')) {
+ return 'Debe ser un número entero positivo mayor a 0';
+ }
+ return '';
+ }
+}
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.html b/frontend/angular/src/app/pages/create-course/create-course.component.html
index 87fb10a..750dcf4 100644
--- a/frontend/angular/src/app/pages/create-course/create-course.component.html
+++ b/frontend/angular/src/app/pages/create-course/create-course.component.html
@@ -6,208 +6,25 @@ Crear Nuevo Curso
-
-
-
-
-
Vista Previa del JSON
-
{{ getPreviewJson() }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.scss b/frontend/angular/src/app/pages/create-course/create-course.component.scss
index fc61aed..d059200 100644
--- a/frontend/angular/src/app/pages/create-course/create-course.component.scss
+++ b/frontend/angular/src/app/pages/create-course/create-course.component.scss
@@ -50,410 +50,6 @@ $success-color: #4caf50;
}
}
-.course-form {
- max-width: 1200px;
- margin: 0 auto;
- padding: 0 120px;
-
- @media (max-width: 768px) {
- padding: 0 20px;
- }
-}
-
-.form-section {
- background: $card-bg;
- border: 1px solid $card-border;
- border-radius: 12px;
- padding: 30px;
- margin-bottom: 30px;
- backdrop-filter: blur(10px);
-
- h2 {
- font-size: 1.5rem;
- margin-bottom: 25px;
- font-weight: 600;
- position: relative;
- color: $text-light;
-
- &:after {
- content: "";
- position: absolute;
- bottom: -8px;
- left: 0;
- width: 60px;
- height: 3px;
- background: linear-gradient(90deg, $primary-color, $secondary-color);
- border-radius: 2px;
- }
- }
-}
-
-.section-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 25px;
-
- @media (max-width: 576px) {
- flex-direction: column;
- gap: 15px;
- align-items: stretch;
- }
-}
-
-.form-group {
- margin-bottom: 20px;
-
- label {
- display: block;
- margin-bottom: 8px;
- font-weight: 500;
- color: $text-light;
- font-size: 0.95rem;
- }
-}
-
-.form-input,
-.form-textarea,
-.form-select {
- width: 100%;
- padding: 12px 16px;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid $card-border;
- border-radius: 8px;
- color: $text-light;
- font-size: 0.95rem;
- transition: all 0.3s ease;
-
- &::placeholder {
- color: rgba(255, 255, 255, 0.5);
- }
-
- &:focus {
- outline: none;
- border-color: $secondary-color;
- box-shadow: 0 0 0 3px rgba(138, 99, 210, 0.1);
- background: rgba(255, 255, 255, 0.15);
- }
-
- &:invalid {
- border-color: $error-color;
- }
-}
-
-.form-textarea {
- resize: vertical;
- min-height: 60px;
-}
-
-.code-textarea {
- width: 100%;
- padding: 16px;
- background: rgba(0, 0, 0, 0.4);
- border: 1px solid $card-border;
- border-radius: 8px;
- color: $text-light;
- font-family: "Courier New", monospace;
- font-size: 0.9rem;
- line-height: 1.4;
- resize: vertical;
-
- &::placeholder {
- color: rgba(255, 255, 255, 0.4);
- }
-
- &:focus {
- outline: none;
- border-color: $primary-color;
- box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1);
- }
-}
-
-.error-message {
- color: $error-color;
- font-size: 0.85rem;
- margin-top: 5px;
- display: block;
-}
-
-// Botones
-.add-button,
-.add-sub-button,
-.add-example-button {
- background: linear-gradient(45deg, $primary-color, $secondary-color);
- color: white;
- border: none;
- padding: 10px 20px;
- border-radius: 6px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.3s ease;
- display: flex;
- align-items: center;
- gap: 8px;
-
- &:hover:not(:disabled) {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(223, 136, 29, 0.3);
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- i {
- font-size: 0.9rem;
- }
-}
-
-.add-sub-button,
-.add-example-button {
- font-size: 0.85rem;
- padding: 8px 16px;
-}
-
-.remove-button,
-.remove-sub-button,
-.remove-example-button {
- background: rgba(255, 82, 82, 0.1); // rojo translúcido sobre fondo oscuro
- color: $error-color;
- border: 1px solid rgba(244, 67, 54, 0.5);
- padding: 8px 14px;
- border-radius: 6px;
- font-weight: 500;
- cursor: pointer;
- font-size: 0.9rem;
- transition: all 0.3s ease;
- display: flex;
- align-items: center;
- gap: 8px;
-
- i {
- font-size: 0.9rem;
- }
-
- &:hover {
- background: rgba(244, 67, 54, 0.2);
- border-color: rgba(244, 67, 54, 0.7);
- transform: scale(1.05);
- }
-
- &:focus {
- outline: none;
- box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.2);
- }
-}
-// Contenedores de contenido con jerarquía visual mejorada
-.contents-container {
- display: flex;
- flex-direction: column;
- gap: 25px;
-}
-
-.content-card {
- background: rgba(255, 255, 255, 0.08);
- border: 2px solid rgba(138, 99, 210, 0.3);
- border-left: 4px solid $secondary-color;
- border-radius: 10px;
- margin-bottom: 2rem;
- position: relative;
- backdrop-filter: blur(10px);
-}
-
-.content-header {
- background: rgba(138, 99, 210, 0.15);
- padding: 1rem;
- border-bottom: 1px solid rgba(138, 99, 210, 0.2);
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 0;
-
- h3 {
- color: $text-light;
- font-size: 1.2rem;
- font-weight: 600;
- margin: 0;
- }
-}
-
-.content-title {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.content-number {
- background: $secondary-color;
- color: white;
- padding: 0.25rem 0.5rem;
- border-radius: 50%;
- font-weight: bold;
- font-size: 0.875rem;
- min-width: 2rem;
- text-align: center;
-}
-
-.content-body {
- padding: 1.5rem;
-}
-
-.subcontent-section {
- margin-top: 2rem;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- padding-top: 1.5rem;
-
- .subcontent-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
-
- h4 {
- color: $primary-color;
- font-size: 1.1rem;
- font-weight: 600;
- margin: 0;
- }
- }
-}
-
-.subcontent-container {
- margin-left: 1rem;
- border-left: 2px solid rgba(255, 255, 255, 0.1);
- padding-left: 1rem;
-}
-
-.subcontent-card {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- margin-bottom: 1rem;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
-}
-
-.subcontent-header-item {
- background: rgba(255, 255, 255, 0.08);
- padding: 0.75rem 1rem;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.subcontent-title {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.subcontent-number {
- background: $primary-color;
- color: white;
- padding: 0.2rem 0.4rem;
- border-radius: 4px;
- font-weight: bold;
- font-size: 0.75rem;
-}
-
-.subcontent-body {
- padding: 1rem;
-}
-
-.examples-section {
- margin-top: 1rem;
- border-top: 1px solid rgba(255, 255, 255, 0.05);
- padding-top: 1rem;
-
- .examples-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 15px;
-
- label {
- margin-bottom: 0;
- font-size: 0.9rem;
- color: $text-muted;
- }
-
- h5 {
- color: rgba(76, 175, 80, 0.9);
- margin: 0;
- font-size: 1rem;
- }
- }
-}
-
-.examples-container {
- margin-left: 1rem;
- border-left: 2px solid rgba(255, 255, 255, 0.05);
- padding-left: 1rem;
-}
-
-.example-item {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 6px;
- margin-bottom: 0.75rem;
- overflow: hidden;
- position: relative;
-}
-
-.example-header {
- background: rgba(76, 175, 80, 0.15);
- padding: 0.5rem 0.75rem;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- border-bottom: 1px solid rgba(76, 175, 80, 0.2);
-}
-
-.example-number {
- background: $success-color;
- color: white;
- padding: 0.15rem 0.3rem;
- border-radius: 3px;
- font-weight: bold;
- font-size: 0.7rem;
-}
-
-.example-label {
- font-size: 0.875rem;
- color: rgba(76, 175, 80, 0.9);
- font-weight: 500;
-}
-
-// Sobrescribimos el code-textarea dentro de examples para mantener coherencia
-.example-item .code-textarea {
- width: 100%;
- border: none;
- background: rgba(0, 0, 0, 0.6);
- font-family: "Courier New", monospace;
- padding: 0.75rem;
- resize: vertical;
- min-height: 120px;
- color: $text-light;
- border-radius: 0;
-
- &::placeholder {
- color: rgba(255, 255, 255, 0.4);
- }
-
- &:focus {
- outline: none;
- border-color: $primary-color;
- box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1);
- }
-}
-
-.remove-example-button {
- margin-left: auto;
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
-}
-
// Acciones del formulario
.form-actions {
display: flex;
diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts b/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts
index e69de29..48c869a 100644
--- a/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts
+++ b/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { CreateCourseComponent } from './create-course.component';
+
+describe('CreateCourseComponent', () => {
+ let component: CreateCourseComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule,CreateCourseComponent],
+ providers: [
+ { provide: Router, useValue: routerSpy }
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CreateCourseComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.ts b/frontend/angular/src/app/pages/create-course/create-course.component.ts
index 4c0c33f..6c3c863 100644
--- a/frontend/angular/src/app/pages/create-course/create-course.component.ts
+++ b/frontend/angular/src/app/pages/create-course/create-course.component.ts
@@ -1,10 +1,10 @@
-import { Component, OnInit } from '@angular/core';
-import { FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
+import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { CommonModule } from '@angular/common';
-import { ReactiveFormsModule } from '@angular/forms';
-import { CoursesService } from '../../services/courses.service'; // Ajusta la ruta según tu estructura
+import { FormCourseComponent } from '../../components/form-course/form-course.component';
+import { CoursesService } from '../../services/courses.service';
+// Interfaces para el tipado de datos
interface CourseData {
course: {
title: string;
@@ -18,31 +18,48 @@ interface ContentData {
title: string;
paragraph: string[];
subcontent: SubcontentData[];
- next: string | null;
+ next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
}
interface SubcontentData {
subtitle: string;
subparagraph: string[];
- example: string[];
+ example: ExampleData[];
}
-interface FormContentValue {
+interface ExampleData {
+ code: string;
+}
+
+// Interface para la creación de curso (compatible con el servicio)
+interface CreateCourseRequest {
+ course: {
+ title: string;
+ description: string;
+ goto: string;
+ };
+ contents: CreateCourseContent[];
+}
+
+interface CreateCourseContent {
title: string;
- paragraph: string;
- subcontent: FormSubcontentValue[];
+ paragraph: string[];
+ subcontent: CreateSubContent[];
+ next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
}
-interface FormSubcontentValue {
+interface CreateSubContent {
subtitle: string;
- subparagraph: string;
- example: string[];
+ subparagraph: string[];
+ example: CreateExampleContent[];
}
-interface FormValue {
- title: string;
- description: string;
- contents: FormContentValue[];
+interface CreateExampleContent {
+ code: string;
}
@Component({
@@ -50,188 +67,116 @@ interface FormValue {
standalone: true,
templateUrl: './create-course.component.html',
styleUrls: ['./create-course.component.scss'],
- imports: [CommonModule, ReactiveFormsModule]
+ imports: [CommonModule, FormCourseComponent]
})
-export class CreateCourseComponent implements OnInit {
- courseForm: FormGroup;
+export class CreateCourseComponent {
+ // Estados del componente
isSubmitting = false;
+ isFormValid = false;
showPreview = false;
+ hasError = false;
+ errorMessage = '';
+ currentCourseData: CourseData | null = null;
constructor(
- private fb: FormBuilder,
private router: Router,
private coursesService: CoursesService
- ) {
- this.courseForm = this.createCourseForm();
- }
+ ) {}
- ngOnInit(): void {
- // Agregar el primer contenido por defecto
- if (this.contents.length === 0) {
- this.addContent();
- }
+ // Manejar cambios en los datos del formulario
+ onFormDataChange(courseData: CourseData): void {
+ this.currentCourseData = courseData;
+ this.hasError = false; // Limpiar errores cuando cambian los datos
}
- private createCourseForm(): FormGroup {
- return this.fb.group({
- title: ['', [Validators.required, Validators.minLength(3)]],
- description: ['', [Validators.required, Validators.minLength(10)]],
- contents: this.fb.array([])
- });
+ // Manejar cambios en la validez del formulario
+ onFormValidChange(isValid: boolean): void {
+ this.isFormValid = isValid;
}
- // Getters para FormArrays
- get contents(): FormArray {
- return this.courseForm.get('contents') as FormArray;
+ // Alternar vista previa del JSON
+ togglePreview(): void {
+ this.showPreview = !this.showPreview;
}
- getSubcontents(contentIndex: number): FormArray {
- return this.contents.at(contentIndex).get('subcontent') as FormArray;
- }
+ // Enviar formulario
+ async onSubmit(): Promise {
+ if (!this.isFormValid || !this.currentCourseData) {
+ console.error('Formulario inválido o sin datos', {
+ isFormValid: this.isFormValid,
+ hasData: !!this.currentCourseData
+ });
+ return;
+ }
- getExamples(contentIndex: number, subcontentIndex: number): FormArray {
- return this.getSubcontents(contentIndex).at(subcontentIndex).get('example') as FormArray;
- }
+ this.isSubmitting = true;
+ this.hasError = false;
- // Métodos para manejar contenidos
- addContent(): void {
- if (this.contents.length < 20) {
- const contentGroup = this.fb.group({
- title: ['', Validators.required],
- paragraph: ['', Validators.required],
- subcontent: this.fb.array([])
- });
+ try {
+ // Validar datos antes de enviar
+ if (!this.validateCourseData(this.currentCourseData)) {
+ throw new Error('Datos del curso inválidos');
+ }
+
+ // Transformar datos al formato esperado por la API
+ const createRequest = this.transformCourseDataForCreation(this.currentCourseData);
+
+ console.log('Datos que se enviarán al servidor:', JSON.stringify(createRequest, null, 2));
- this.contents.push(contentGroup);
+ // Llamar al servicio para crear el curso
+ await this.createCourse(createRequest);
- // Agregar el primer subcontenido automáticamente
- this.addSubcontent(this.contents.length - 1);
+ alert('¡Curso creado exitosamente!');
+ this.router.navigate(['/cursos']);
- // Actualizar las referencias "next" automáticamente
- this.updateNextOptions();
+ } catch (error) {
+ console.error('Error al crear el curso:', error);
+ this.hasError = true;
+ this.errorMessage = error instanceof Error ? error.message : 'Error desconocido';
+ alert(`Error al crear el curso: ${this.errorMessage}`);
+ } finally {
+ this.isSubmitting = false;
}
}
- removeContent(index: number): void {
- if (this.contents.length > 1) {
- this.contents.removeAt(index);
- this.updateNextOptions();
+ // Validar datos del curso
+ private validateCourseData(courseData: CourseData): boolean {
+ if (!courseData.course) {
+ console.error('Datos del curso faltantes');
+ return false;
}
- }
-
- // Métodos para manejar subcontenidos
- addSubcontent(contentIndex: number): void {
- const subcontentGroup = this.fb.group({
- subtitle: ['', Validators.required],
- subparagraph: ['', Validators.required],
- example: this.fb.array([this.fb.control('', Validators.required)])
- });
- this.getSubcontents(contentIndex).push(subcontentGroup);
- }
-
- removeSubcontent(contentIndex: number, subcontentIndex: number): void {
- const subcontents = this.getSubcontents(contentIndex);
- if (subcontents.length > 1) {
- subcontents.removeAt(subcontentIndex);
+ if (!courseData.course.title || !courseData.course.description || !courseData.course.goto) {
+ console.error('Campos requeridos del curso faltantes');
+ return false;
}
- }
-
- // Métodos para manejar ejemplos
- addExample(contentIndex: number, subcontentIndex: number): void {
- this.getExamples(contentIndex, subcontentIndex).push(
- this.fb.control('', Validators.required)
- );
- }
- removeExample(contentIndex: number, subcontentIndex: number, exampleIndex: number): void {
- const examples = this.getExamples(contentIndex, subcontentIndex);
- if (examples.length > 1) {
- examples.removeAt(exampleIndex);
+ if (!Array.isArray(courseData.contents) || courseData.contents.length === 0) {
+ console.error('Contenidos del curso faltantes o vacíos');
+ return false;
}
- }
-
- // Actualizar opciones de "siguiente contenido" automáticamente
- updateNextOptions(): void {
- // Esta función ahora solo actualiza los valores "next" internamente
- // No se muestra en el formulario, pero se usa en el JSON final
- }
- // Generar ruta de acceso automáticamente desde el título
- private generateGoto(title: string): string {
- return title
- .toLowerCase()
- .replace(/[^a-z0-9\s]/g, '') // Eliminar caracteres especiales
- .replace(/\s+/g, '-') // Reemplazar espacios con guiones
- .trim();
- }
-
- // Formatear datos para el JSON final
- private formatCourseData(): CourseData {
- const formValue = this.courseForm.value as FormValue;
-
- const contents: ContentData[] = formValue.contents.map((content: FormContentValue, index: number) => {
- const contentData: ContentData = {
- title: content.title,
- paragraph: [content.paragraph],
- subcontent: content.subcontent.map((sub: FormSubcontentValue) => ({
- subtitle: sub.subtitle,
- subparagraph: [sub.subparagraph],
- example: sub.example.filter((ex: string) => ex.trim() !== '')
- })),
- next: null // Inicializar next como null por defecto
- };
-
- // Agregar "next" automáticamente si no es el último contenido
- if (index < formValue.contents.length - 1) {
- const nextContent = formValue.contents[index + 1];
- // Verificar que el siguiente contenido existe y tiene título
- if (nextContent && nextContent.title && nextContent.title.trim() !== '') {
- contentData.next = nextContent.title.trim();
- }
+ // Validar cada contenido
+ for (const content of courseData.contents) {
+ if (!content.title) {
+ console.error('Título del contenido faltante');
+ return false;
}
- return contentData;
- });
-
- return {
- course: {
- title: formValue.title,
- description: formValue.description,
- goto: this.generateGoto(formValue.title)
- },
- contents
- };
- }
-
- // Vista previa del JSON
- getPreviewJson(): string {
- if (this.courseForm.valid) {
- return JSON.stringify(this.formatCourseData(), null, 2);
- }
- return 'Completa todos los campos requeridos para ver la vista previa';
- }
-
- togglePreview(): void {
- this.showPreview = !this.showPreview;
- }
+ if (!Array.isArray(content.paragraph)) {
+ console.error('Párrafos del contenido deben ser un array');
+ return false;
+ }
- // Validación personalizada
- private validateCourse(): boolean {
- // Validar que cada contenido tenga al menos un subcontenido
- for (let i = 0; i < this.contents.length; i++) {
- const subcontents = this.getSubcontents(i);
- if (subcontents.length === 0) {
- alert(`El contenido ${i + 1} debe tener al menos un subcontenido`);
+ if (!Array.isArray(content.subcontent)) {
+ console.error('Subcontenidos deben ser un array');
return false;
}
- // Validar que cada subcontenido tenga al menos un ejemplo
- for (let j = 0; j < subcontents.length; j++) {
- const examples = this.getExamples(i, j);
- const validExamples = examples.controls.filter(ex => ex.value.trim() !== '');
- if (validExamples.length === 0) {
- alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo`);
+ // Validar subcontenidos
+ for (const sub of content.subcontent) {
+ if (!sub.subtitle || !Array.isArray(sub.subparagraph) || !Array.isArray(sub.example)) {
+ console.error('Estructura de subcontenido inválida');
return false;
}
}
@@ -240,67 +185,92 @@ export class CreateCourseComponent implements OnInit {
return true;
}
- // MÉTODO ACTUALIZADO: Envío del formulario con integración a la API
- onSubmit(): void {
- if (this.courseForm.valid && this.validateCourse()) {
- this.isSubmitting = true;
-
- const courseData = this.formatCourseData();
-
- // Enviar el curso a la API
+ // Transformar datos para la creación
+ private transformCourseDataForCreation(courseData: CourseData): CreateCourseRequest {
+ return {
+ course: {
+ title: courseData.course.title.trim(),
+ description: courseData.course.description.trim(),
+ goto: courseData.course.goto.trim()
+ },
+ contents: courseData.contents.map(content => ({
+ title: content.title.trim(),
+ paragraph: content.paragraph.filter(p => p.trim() !== ''),
+ subcontent: content.subcontent.map(sub => ({
+ subtitle: sub.subtitle.trim(),
+ subparagraph: sub.subparagraph.filter(sp => sp.trim() !== ''),
+ example: sub.example.map(ex => ({
+ code: ex.code.trim()
+ }))
+ })),
+ next: content.next?.trim() || null,
+ maxResourceConsumption: Number(content.maxResourceConsumption) || 100,
+ maxProcessingTime: Number(content.maxProcessingTime) || 5000
+ }))
+ };
+ }
+
+ // Crear curso usando el coursesService
+ private async createCourse(courseData: CreateCourseRequest): Promise {
+ return new Promise((resolve, reject) => {
this.coursesService.createCourse(courseData).subscribe({
next: (response) => {
console.log('Curso creado exitosamente:', response);
-
- // Mostrar mensaje de éxito
- alert(`¡Curso creado exitosamente! ID: ${response.id}`);
-
- // Opcional: Resetear el formulario o navegar a otra página
- this.courseForm.reset();
- this.contents.clear();
- this.addContent(); // Agregar contenido inicial
-
- // Opcional: Navegar a la lista de cursos o a ver el curso creado
- // this.router.navigate(['/courses']);
-
- this.isSubmitting = false;
+ resolve();
},
error: (error) => {
- console.error('Error al crear el curso:', error);
- alert('Error al crear el curso: ' + error.message);
- this.isSubmitting = false;
+ console.error('Error detallado del servidor:', error);
+
+ // Proporcionar más información sobre el error
+ let errorMessage = 'Error interno del servidor';
+ if (error.error && typeof error.error === 'object') {
+ errorMessage = error.error.message || error.error.error || errorMessage;
+ } else if (error.message) {
+ errorMessage = error.message;
+ }
+
+ reject(new Error(errorMessage));
}
});
-
- } else {
- this.markFormGroupTouched(this.courseForm);
- alert('Por favor, completa todos los campos requeridos');
- }
+ });
}
+ // Cancelar creación
onCancel(): void {
- if (confirm('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios.')) {
- this.courseForm.reset();
- this.contents.clear();
- this.addContent(); // Agregar contenido inicial
+ const hasChanges = this.currentCourseData !== null;
+
+ if (hasChanges) {
+ const confirmLeave = confirm('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios.');
+ if (!confirmLeave) {
+ return;
+ }
}
+
+ this.router.navigate(['/cursos']);
}
- // Marcar todos los campos como tocados para mostrar errores
- private markFormGroupTouched(formGroup: FormGroup | FormArray): void {
- Object.keys(formGroup.controls).forEach(key => {
- const control = formGroup.get(key);
- if (control instanceof FormGroup || control instanceof FormArray) {
- this.markFormGroupTouched(control);
- } else {
- control?.markAsTouched();
- }
- });
+ // Reintentar en caso de error
+ onRetry(): void {
+ this.hasError = false;
+ this.errorMessage = '';
+ this.onSubmit();
+ }
+
+ // Método para obtener datos del curso para debugging
+ getCourseData(): CourseData | null {
+ return this.currentCourseData;
+ }
+
+ // Getters para el template
+ get hasFormData(): boolean {
+ return this.currentCourseData !== null;
+ }
+
+ get canSubmit(): boolean {
+ return this.isFormValid && this.hasFormData && !this.isSubmitting;
}
- // Método para actualizar ruta automáticamente cuando cambia el título
- onTitleChange(): void {
- // Este método se mantiene para compatibilidad con el template
- // La generación de goto ahora es automática en formatCourseData()
+ get hasFormError(): boolean {
+ return this.hasError;
}
}
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/cursos/cursos.component.html b/frontend/angular/src/app/pages/cursos/cursos.component.html
index 349d7dc..ce7b4b2 100644
--- a/frontend/angular/src/app/pages/cursos/cursos.component.html
+++ b/frontend/angular/src/app/pages/cursos/cursos.component.html
@@ -1,9 +1,7 @@
-
+
@for (curso of cursos; track curso.id) {
-
}
diff --git a/frontend/angular/src/app/pages/cursos/cursos.component.scss b/frontend/angular/src/app/pages/cursos/cursos.component.scss
index 026160b..fb51d4c 100644
--- a/frontend/angular/src/app/pages/cursos/cursos.component.scss
+++ b/frontend/angular/src/app/pages/cursos/cursos.component.scss
@@ -1,18 +1,13 @@
.cursos-container {
- height: 100%;
- width: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
+ padding: 40px 20px;
+ max-width: 1200px;
+ margin: 0 auto;
}
-.cards-container {
- width: 50%;
+.cards-grid {
display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- grid-auto-rows: 150px;
- row-gap: 1rem;
- column-gap: 1rem;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 24px;
}
.single-card :hover {
diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.html b/frontend/angular/src/app/pages/edit-course/edit-course.component.html
new file mode 100644
index 0000000..b081525
--- /dev/null
+++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.html
@@ -0,0 +1,31 @@
+
+
+
+
Editar Curso
+
Modifica y perfecciona tu curso de programación
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.scss b/frontend/angular/src/app/pages/edit-course/edit-course.component.scss
new file mode 100644
index 0000000..f8b163b
--- /dev/null
+++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.scss
@@ -0,0 +1,217 @@
+@use "sass:color";
+
+// Variables de color
+$primary-color: #4346ff; // azul vibrante
+$secondary-color: #ff6a81; // rosa coral
+$background-dark: #000000; // fondo negro total
+$background-light: #272149; // morado oscuro
+$text-light: #ffffff;
+$text-muted: rgba(255, 255, 255, 0.8);
+$card-bg: rgba(255, 255, 255, 0.05);
+$card-border: rgba(255, 255, 255, 0.1);
+$error-color: #f44336;
+$success-color: #4caf50;
+$warning-color: #ff9800;
+
+.edit-course-container {
+ min-height: 100vh;
+ color: $text-light;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+ padding-bottom: 60px;
+}
+
+.hero-section {
+ padding: 20px;
+
+ @media (max-width: 768px) {
+ padding: 30px 20px 15px;
+ }
+
+ .hero-content {
+ text-align: center;
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+
+ h1 {
+ font-size: 2.5rem;
+ margin-bottom: 1rem;
+ font-weight: 700;
+ color: $text-light;
+ background: linear-gradient(45deg, $primary-color, $secondary-color);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ width: fit-content;
+ }
+
+ .welcome-message {
+ font-size: 1.1rem;
+ opacity: 0.9;
+ color: $text-muted;
+ }
+ }
+}
+
+// Acciones del formulario
+.form-actions {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ margin-top: 40px;
+ padding: 0 120px;
+
+ @media (max-width: 768px) {
+ padding: 0 20px;
+ flex-direction: column;
+ }
+}
+
+.cancel-button {
+ background: transparent;
+ color: $text-muted;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 14px 32px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: $text-light;
+ border-color: rgba(255, 255, 255, 0.5);
+ }
+}
+
+.preview-button {
+ background: rgba(255, 255, 255, 0.1);
+ color: $text-light;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 14px 32px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.5);
+ }
+
+ &.active {
+ background: linear-gradient(45deg, rgba($primary-color, 0.2), rgba($secondary-color, 0.2));
+ border-color: $primary-color;
+ color: $primary-color;
+ }
+}
+
+.submit-button {
+ background: linear-gradient(45deg, $primary-color, $secondary-color);
+ color: white;
+ border: none;
+ padding: 14px 32px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ min-width: 180px;
+
+ &:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba($primary-color, 0.4);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ background: rgba(255, 255, 255, 0.1);
+ color: $text-muted;
+ }
+}
+
+// Loading state
+.loading-state {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 200px;
+ color: $text-muted;
+ font-size: 1.1rem;
+
+ .spinner {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 3px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: $primary-color;
+ animation: spin 1s ease-in-out infinite;
+ margin-right: 10px;
+ }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+// Error state
+.error-state {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: 200px;
+ color: $error-color;
+ text-align: center;
+ padding: 20px;
+
+ .error-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+ }
+
+ .error-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ }
+
+ .error-message {
+ color: $text-muted;
+ margin-bottom: 1.5rem;
+ }
+
+ .retry-button {
+ background: $error-color;
+ color: white;
+ border: none;
+ padding: 12px 24px;
+ border-radius: 6px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &:hover {
+ background: color.adjust($error-color, $lightness: -10%);
+ }
+ }
+}
+
+// Responsive
+@media (max-width: 576px) {
+ .form-actions {
+ flex-direction: column;
+
+ .cancel-button,
+ .preview-button,
+ .submit-button {
+ width: 100%;
+ }
+ }
+
+ .hero-section .hero-content h1 {
+ font-size: 2rem;
+ }
+}
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts
new file mode 100644
index 0000000..3c07009
--- /dev/null
+++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts
@@ -0,0 +1,37 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router, ActivatedRoute } from '@angular/router';
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditCourseComponent } from './edit-course.component';
+
+describe('EditCourseComponent', () => {
+ let component: EditCourseComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async () => {
+ const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+ const activatedRouteSpy = {
+ snapshot: {
+ paramMap: {
+ get: jasmine.createSpy('get').and.returnValue('123')
+ }
+ }
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [EditCourseComponent,HttpClientTestingModule],
+ providers: [
+ { provide: Router, useValue: routerSpy },
+ { provide: ActivatedRoute, useValue: activatedRouteSpy }
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(EditCourseComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts
new file mode 100644
index 0000000..e5b8f16
--- /dev/null
+++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts
@@ -0,0 +1,453 @@
+import { Component, OnInit } from '@angular/core';
+import { Router, ActivatedRoute } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { FormCourseComponent } from '../../components/form-course/form-course.component';
+import { CoursesService } from '../../services/courses.service';
+
+import { HttpErrorResponse } from '@angular/common/http';
+
+
+// Interfaces para el tipado de datos
+interface CourseData {
+ course: {
+ id?: number;
+ title: string;
+ description: string;
+ goto: string;
+ };
+ contents: ContentData[];
+}
+
+interface ContentData {
+ id?: number;
+ title: string;
+ paragraph: string[];
+ subcontent: SubcontentData[];
+ next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
+}
+
+interface SubcontentData {
+ subtitle: string;
+ subparagraph: string[];
+ example: ExampleData[];
+}
+
+interface ExampleData {
+ code: string;
+}
+
+// Interface para actualización completa (PUT) - ÚNICA INTERFACE DE ACTUALIZACIÓN
+interface UpdateCourseRequest {
+ course: {
+ title: string;
+ description: string;
+ goto: string;
+ };
+ contents: UpdateContentData[];
+}
+
+interface UpdateContentData {
+ title: string;
+ paragraph: string[];
+ subcontent: UpdateSubcontentData[];
+ next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
+}
+
+interface UpdateSubcontentData {
+ subtitle: string;
+ subparagraph: string[];
+ example: UpdateExampleData[];
+}
+
+interface UpdateExampleData {
+ code: string;
+}
+
+// Interface para el resultado de validación
+interface ValidationResult {
+ isValid: boolean;
+ errors: string[];
+}
+
+@Component({
+ selector: 'app-edit-course',
+ standalone: true,
+ templateUrl: './edit-course.component.html',
+ styleUrls: ['./edit-course.component.scss'],
+ imports: [CommonModule, FormCourseComponent]
+})
+export class EditCourseComponent implements OnInit {
+ // Estados del componente
+ isSubmitting = false;
+ isFormValid = false;
+ showPreview = false;
+ isLoading = true;
+ hasError = false;
+ errorMessage = '';
+ hasChanges = false;
+
+ // Datos del curso
+ courseId: string | null = null;
+ initialCourseData: CourseData | undefined = undefined;
+ currentCourseData: CourseData | undefined = undefined;
+
+ constructor(
+ private router: Router,
+ private route: ActivatedRoute,
+ private coursesService: CoursesService
+ ) {}
+
+ ngOnInit(): void {
+ this.loadCourseData();
+ }
+
+ // Cargar datos del curso desde la ruta
+ private async loadCourseData(): Promise {
+ try {
+ this.isLoading = true;
+ this.hasError = false;
+
+ // Obtener ID del curso desde la ruta
+ this.courseId = this.route.snapshot.paramMap.get('id');
+
+ if (!this.courseId) {
+ throw new Error('ID del curso no encontrado');
+ }
+
+ // Validar que el ID sea un número válido
+ const numericId = parseInt(this.courseId, 10);
+ if (isNaN(numericId)) {
+ throw new Error('ID del curso inválido');
+ }
+
+ // Cargar datos del curso desde la API
+ this.initialCourseData = await this.fetchCourseData(numericId);
+ this.currentCourseData = JSON.parse(JSON.stringify(this.initialCourseData)); // Deep copy
+
+ } catch (error) {
+ console.error('Error al cargar el curso:', error);
+ this.hasError = true;
+ this.errorMessage = error instanceof Error ? error.message : 'Error desconocido';
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+
+
+private async fetchCourseData(courseId: number): Promise {
+ return new Promise((resolve, reject) => {
+this.coursesService.getCourseById(courseId).subscribe({
+ next: (courseData) => {
+ const transformedData: CourseData = {
+course: {
+id: courseData.course.id,
+title: courseData.course.title,
+description: courseData.course.description,
+goto: courseData.course.goto
+},
+contents: courseData.contents.map(content => ({
+id: content.id,
+title: content.title,
+ paragraph: Array.isArray(content.paragraph) ? content.paragraph : [],
+subcontent: Array.isArray(content.subcontent) ? content.subcontent.map(sub => ({
+ subtitle: sub.subtitle || '',
+ subparagraph: Array.isArray(sub.subparagraph) ? sub.subparagraph : [],
+example: Array.isArray(sub.example)
+? sub.example.map((ex: string | { code?: string } | object) => {
+if (typeof ex === 'string') {
+return { code: ex };
+ } else if (typeof ex === 'object' && ex !== null && 'code' in ex) {
+ return { code: (ex as { code?: string }).code || String(ex) };
+} else {
+ return { code: JSON.stringify(ex) };
+}
+})
+: []
+})) : [],
+next: content.next || null,
+maxResourceConsumption: content.maxResourceConsumption || 100,
+maxProcessingTime: content.maxProcessingTime || 5000
+ }))
+};
+
+ console.log('Datos transformados:', transformedData);
+resolve(transformedData);
+ },
+error: (error) => {
+console.error('Error al obtener el curso:', error);
+reject(new Error('No se pudo cargar el curso: ' + error.message));
+}
+ });
+});
+}
+
+
+
+ // Manejar cambios en los datos del formulario
+ onFormDataChange(courseData: CourseData): void {
+ this.currentCourseData = courseData;
+ this.checkForChanges();
+ }
+
+ // Manejar cambios en la validez del formulario
+ onFormValidChange(isValid: boolean): void {
+ this.isFormValid = isValid;
+ }
+
+ // Verificar si hay cambios en el formulario
+ private checkForChanges(): void {
+ if (!this.initialCourseData || !this.currentCourseData) {
+ this.hasChanges = false;
+ return;
+ }
+
+ const initialJson = JSON.stringify(this.initialCourseData);
+ const currentJson = JSON.stringify(this.currentCourseData);
+ this.hasChanges = initialJson !== currentJson;
+ }
+
+ // Alternar vista previa del JSON
+ togglePreview(): void {
+ this.showPreview = !this.showPreview;
+ }
+
+ // Cancelar edición y volver a la lista de cursos
+ onCancel(): void {
+ // Si hay cambios sin guardar, mostrar confirmación
+ if (this.hasChanges) {
+ const confirmCancel = confirm(
+ 'Tienes cambios sin guardar. ¿Estás seguro de que quieres salir sin guardar?'
+ );
+
+ if (!confirmCancel) {
+ return; // El usuario decidió no cancelar
+ }
+ }
+
+ // Navegar de vuelta a la lista de cursos
+ this.router.navigate(['/cursos']);
+ }
+
+
+
+ // FUNCIÓN DE ENVÍO DE FORMULARIO - SOLO USA PUT
+ // FUNCIÓN DE ENVÍO DE FORMULARIO MEJORADA - SOLO USA PUT
+async onSubmit(): Promise {
+ // Validación previa al envío
+ const validation = this.preSubmitValidation();
+ if (!validation.isValid) {
+ console.error('Validación previa falló:', validation.errors);
+ alert(`No se puede enviar el formulario:\n${validation.errors.join('\n')}`);
+ return;
+ }
+
+ this.isSubmitting = true;
+
+ try {
+ const courseId = parseInt(this.courseId!, 10);
+
+ // Transformar datos para el formato requerido por el backend
+ const fullUpdateData = this.transformCourseDataForUpdate(this.currentCourseData!);
+
+ console.log('=== DATOS ENVIADOS AL BACKEND ===');
+ console.log('Course ID:', courseId);
+ console.log('Datos completos (PUT):', JSON.stringify(fullUpdateData, null, 2));
+
+ // Usar únicamente actualización completa (PUT)
+ await this.updateCourse(courseId, fullUpdateData);
+
+ // Actualizar datos iniciales después del guardado exitoso
+ this.initialCourseData = JSON.parse(JSON.stringify(this.currentCourseData));
+ this.hasChanges = false;
+
+ alert('¡Curso actualizado exitosamente!');
+ this.router.navigate(['/cursos']);
+
+ } catch (error) {
+ console.error('Error al actualizar el curso:', error);
+ const errorMessage = this.handleNetworkError(error);
+ alert(`Error al actualizar el curso: ${errorMessage}`);
+ } finally {
+ this.isSubmitting = false;
+ }
+}
+
+// TRANSFORMACIÓN MEJORADA PARA PUT
+private transformCourseDataForUpdate(courseData: CourseData): UpdateCourseRequest {
+ console.log('=== TRANSFORMANDO DATOS ===');
+ console.log('Datos originales:', courseData);
+
+ const transformed = {
+ course: {
+ title: courseData.course.title.trim(),
+ description: courseData.course.description.trim(),
+ goto: courseData.course.goto.trim()
+ },
+ contents: courseData.contents.map((content, index) => {
+ console.log(`Procesando contenido ${index + 1}:`, content);
+
+ return {
+ title: content.title.trim(),
+ paragraph: content.paragraph
+ .filter(p => p && p.trim() !== '')
+ .map(p => p.trim()),
+ subcontent: content.subcontent.map((sub, subIndex) => {
+ console.log(` Procesando subcontenido ${subIndex + 1}:`, sub);
+
+ return {
+ subtitle: sub.subtitle.trim(),
+ subparagraph: sub.subparagraph
+ .filter(sp => sp && sp.trim() !== '')
+ .map(sp => sp.trim()),
+ example: sub.example.map((ex, exIndex) => {
+ console.log(` Procesando ejemplo ${exIndex + 1}:`, ex);
+ return {
+ code: (ex.code || '').trim()
+ };
+ })
+ };
+ }),
+ next: content.next && content.next.trim() !== '' ? content.next.trim() : null,
+ maxResourceConsumption: Number(content.maxResourceConsumption) || 100,
+ maxProcessingTime: Number(content.maxProcessingTime) || 5000
+ };
+ })
+ };
+
+ console.log('Datos transformados:', transformed);
+ return transformed;
+}
+
+// VALIDACIÓN PREVIA MEJORADA
+private preSubmitValidation(): ValidationResult {
+ const errors: string[] = [];
+
+ // Verificar que los datos actuales existan
+ if (!this.currentCourseData) {
+ errors.push('No hay datos del curso para validar');
+ return { isValid: false, errors };
+ }
+
+ console.log('=== VALIDANDO DATOS ===');
+ console.log('Datos a validar:', this.currentCourseData);
+
+ // Validar datos del curso
+ const course = this.currentCourseData.course;
+ if (!course.title || course.title.trim().length === 0) {
+ errors.push('El título del curso es requerido');
+ }
+ if (!course.description || course.description.trim().length === 0) {
+ errors.push('La descripción del curso es requerida');
+ }
+ if (!course.goto || course.goto.trim().length === 0) {
+ errors.push('La URL del curso (goto) es requerida');
+ }
+
+ // Validar contenidos
+ if (!this.currentCourseData.contents || this.currentCourseData.contents.length === 0) {
+ errors.push('El curso debe tener al menos un contenido');
+ } else {
+ this.currentCourseData.contents.forEach((content, index) => {
+ if (!content.title || content.title.trim().length === 0) {
+ errors.push(`El contenido ${index + 1} debe tener un título`);
+ }
+ if (!content.paragraph || content.paragraph.length === 0 ||
+ content.paragraph.every(p => !p || p.trim() === '')) {
+ errors.push(`El contenido ${index + 1} debe tener al menos un párrafo válido`);
+ }
+ if (!content.maxResourceConsumption || content.maxResourceConsumption <= 0) {
+ errors.push(`El contenido ${index + 1} debe tener un consumo máximo de recursos válido`);
+ }
+ if (!content.maxProcessingTime || content.maxProcessingTime <= 0) {
+ errors.push(`El contenido ${index + 1} debe tener un tiempo máximo de procesamiento válido`);
+ }
+
+ // Validar subcontenidos si existen
+ if (content.subcontent && content.subcontent.length > 0) {
+ content.subcontent.forEach((subcontent, subIndex) => {
+ if (!subcontent.subtitle || subcontent.subtitle.trim().length === 0) {
+ errors.push(`El subcontenido ${subIndex + 1} del contenido ${index + 1} debe tener un subtítulo`);
+ }
+ });
+ }
+ });
+ }
+
+ // Verificar que el formulario sea válido según el componente hijo
+ if (!this.isFormValid) {
+ errors.push('El formulario contiene errores de validación');
+ }
+
+ // Verificar que haya cambios para guardar
+ if (!this.hasChanges) {
+ errors.push('No hay cambios para guardar');
+ }
+
+ console.log('Errores de validación:', errors);
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+}
+
+// LLAMADA AL SERVICIO MEJORADA
+private async updateCourse(courseId: number, courseData: UpdateCourseRequest): Promise {
+ return new Promise((resolve, reject) => {
+ console.log('=== ENVIANDO SOLICITUD PUT ===');
+ console.log('URL:', `courses/${courseId}`);
+ console.log('Datos:', JSON.stringify(courseData, null, 2));
+
+ this.coursesService.updateCourse(courseId, courseData).subscribe({
+ next: (response) => {
+ console.log('=== RESPUESTA EXITOSA ===');
+ console.log('Respuesta del servidor:', response);
+ resolve();
+ },
+ error: (error) => {
+ console.error('=== ERROR EN SOLICITUD ===');
+ console.error('Error completo:', error);
+ console.error('Status:', error.status);
+ console.error('Message:', error.message);
+ console.error('Error body:', error.error);
+ reject(error);
+ }
+ });
+ });
+}
+
+ // Manejar errores de red
+ private handleNetworkError(error: unknown): string {
+ if (error instanceof HttpErrorResponse) {
+ if (error?.error?.message) {
+ return error.error.message;
+ }
+ if (error?.message) {
+ return error.message;
+ }
+ if (error?.status) {
+ switch (error.status) {
+ case 400:
+ return 'Datos inválidos enviados al servidor';
+ case 401:
+ return 'No autorizado - verifica tu sesión';
+ case 403:
+ return 'No tienes permisos para realizar esta acción';
+ case 404:
+ return 'Curso no encontrado';
+ case 500:
+ return 'Error interno del servidor';
+ default:
+ return `Error del servidor (${error.status})`;
+ }
+ }
+ }
+
+ return 'Error de conexión con el servidor';
+ }
+}
\ No newline at end of file
diff --git a/frontend/angular/src/app/pages/introduction/introduction.component.html b/frontend/angular/src/app/pages/introduction/introduction.component.html
index f82e75b..502efd9 100644
--- a/frontend/angular/src/app/pages/introduction/introduction.component.html
+++ b/frontend/angular/src/app/pages/introduction/introduction.component.html
@@ -1,4 +1,4 @@
-
+
@if (loading) {
@@ -46,7 +46,7 @@ {{ subcontent.subtitle }}
Ejemplo
-
{{ subcontent.example[0] }}
+
{{ getExampleCode(subcontent.example[0]) }}
}
diff --git a/frontend/angular/src/app/pages/introduction/introduction.component.ts b/frontend/angular/src/app/pages/introduction/introduction.component.ts
index 6cff385..c19438f 100644
--- a/frontend/angular/src/app/pages/introduction/introduction.component.ts
+++ b/frontend/angular/src/app/pages/introduction/introduction.component.ts
@@ -249,4 +249,20 @@ export class IntroductionComponent implements OnInit {
getCurrentCourseInfo(): CursoDisponible | null {
return this.coursesService.getCourseInfoByGoto(this.currentGoto);
}
-}
\ No newline at end of file
+ getExampleCode(example: string | { code?: string } | object): string {
+ if (typeof example === 'string') {
+ return example;
+ }
+
+ if (example && typeof example === 'object' && 'code' in example) {
+ return (example as { code: string }).code;
+ }
+
+ if (example && typeof example === 'object') {
+ return JSON.stringify(example, null, 2);
+ }
+
+ return 'Código no disponible';
+}
+
+}
diff --git a/frontend/angular/src/app/services/courses.service.ts b/frontend/angular/src/app/services/courses.service.ts
index fb7056c..ff42761 100644
--- a/frontend/angular/src/app/services/courses.service.ts
+++ b/frontend/angular/src/app/services/courses.service.ts
@@ -26,6 +26,8 @@ interface ContenidoCurso {
paragraph: string[];
subcontent: SubContent[];
next?: string;
+ maxResourceConsumption?: number;
+ maxProcessingTime?: number;
instrucciones?: string;
codigo_incompleto?: string;
solucion_correcta?: string;
@@ -63,19 +65,53 @@ interface CreateCourseContent {
paragraph: string[];
subcontent: CreateSubContent[];
next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
}
interface CreateSubContent {
subtitle: string;
subparagraph: string[];
- example: string[];
+ example: CreateExampleContent[];
}
-// Interface para la respuesta de creación
-interface CreateCourseResponse {
- id: number;
+interface CreateExampleContent {
+ code: string;
+}
+
+// Interface para actualización completa (PUT) - ÚNICA INTERFACE DE ACTUALIZACIÓN
+interface UpdateCourseRequest {
+ course: {
+ title: string;
+ description: string;
+ goto: string;
+ };
+ contents: UpdateCourseContent[];
+}
+
+interface UpdateCourseContent {
+ title: string;
+ paragraph: string[];
+ subcontent: UpdateSubContent[];
+ next: string | null;
+ maxResourceConsumption: number;
+ maxProcessingTime: number;
+}
+
+interface UpdateSubContent {
+ subtitle: string;
+ subparagraph: string[];
+ example: UpdateExampleContent[];
+}
+
+interface UpdateExampleContent {
+ code: string;
+}
+
+// Interface para la respuesta de creación/actualización
+interface CourseResponse {
message: string;
- course?: CourseData;
+ course_id: number;
}
@Injectable({
@@ -94,9 +130,26 @@ export class CoursesService {
constructor(private http: HttpClient) {}
- // NUEVA FUNCIÓN: Crear un nuevo curso
- createCourse(courseData: CreateCourseRequest): Observable {
- return this.http.post(`${this.baseUrl}/courses`, courseData).pipe(
+ // Crear un nuevo curso
+ createCourse(courseData: CreateCourseRequest): Observable {
+ return this.http.post(`${this.baseUrl}/courses`, courseData).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // Actualización completa (PUT) - ÚNICA FUNCIÓN DE ACTUALIZACIÓN
+ updateCourse(courseId: number, courseData: UpdateCourseRequest): Observable {
+ console.log('Sending PUT request to:', `${this.baseUrl}/courses/${courseId}`);
+ console.log('Data:', JSON.stringify(courseData, null, 2));
+
+ return this.http.put(`${this.baseUrl}/courses/${courseId}`, courseData).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // Eliminar un curso
+ deleteCourse(courseId: number): Observable<{ message: string }> {
+ return this.http.delete<{ message: string }>(`${this.baseUrl}/courses/${courseId}`).pipe(
catchError(this.handleError)
);
}
@@ -233,15 +286,27 @@ export class CoursesService {
private handleError = (error: HttpErrorResponse): Observable => {
let errorMessage = 'Ha ocurrido un error desconocido';
+
+ console.error('HTTP Error:', error);
+
if (error.error instanceof ErrorEvent) {
errorMessage = `Error: ${error.error.message}`;
} else {
switch (error.status) {
+ case 400:
+ errorMessage = 'Datos inválidos enviados al servidor';
+ if (error.error && error.error.details) {
+ errorMessage += `: ${error.error.details}`;
+ }
+ break;
case 404:
errorMessage = 'Curso no encontrado';
break;
case 500:
errorMessage = 'Error interno del servidor';
+ if (error.error && error.error.details) {
+ errorMessage += `: ${error.error.details}`;
+ }
break;
case 0:
errorMessage = 'No se puede conectar con el servidor. Verifica tu conexión.';
@@ -249,7 +314,18 @@ export class CoursesService {
default:
errorMessage = `Error del servidor: ${error.status} - ${error.message}`;
}
+
+ // Agregar detalles adicionales si están disponibles
+ if (error.error && typeof error.error === 'object') {
+ if (error.error.error) {
+ errorMessage += ` - ${error.error.error}`;
+ }
+ if (error.error.message && error.error.message !== error.error.error) {
+ errorMessage += ` - ${error.error.message}`;
+ }
+ }
}
+
console.error('Error en CoursesService:', errorMessage, error);
return throwError(() => new Error(errorMessage));
}
diff --git a/frontend/angular/src/app/shared/editor/editor.component.ts b/frontend/angular/src/app/shared/editor/editor.component.ts
index 02e89f3..341c98a 100644
--- a/frontend/angular/src/app/shared/editor/editor.component.ts
+++ b/frontend/angular/src/app/shared/editor/editor.component.ts
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-// EditorComponent.ts - Versión con límites usando Web Workers
+// EditorComponent.ts - Versión con límites funcionando correctamente
import { Component, OnInit, AfterViewInit, ViewChild, OnDestroy, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -96,7 +96,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy, OnChan
// Nuevos inputs para límites de ejecución
@Input() timeoutSeconds = 5; // Límite de tiempo en segundos
- @Input() memoryLimitMB = 50; // Límite de memoria en MB
+ @Input() memoryLimitMB = 30; // Límite de memoria en MB
@Input() maxOutputLines = 1000; // Límite de líneas de output
// Outputs para comunicación con el componente padre - CORREGIDOS
@@ -200,8 +200,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy, OnChan
codeEditor: any;
private lspSubscription: Subscription | null = null;
private completionItems: { label: string }[] = [];
- private executionWorker: Worker | null = null;
private executionTimeoutHandle: any = null;
+ private executionStartTime = 0;
constructor(
private http: HttpClient,
@@ -353,101 +353,80 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy, OnChan
if (this.lspSubscription) {
this.lspSubscription.unsubscribe();
}
- if (this.executionWorker) {
- this.executionWorker.terminate();
- }
if (this.executionTimeoutHandle) {
clearTimeout(this.executionTimeoutHandle);
}
}
private createSecurePythonCode(code: string): string {
- // Código de seguridad que se inyecta para limitar ejecución
+ // Sistema de límites simplificado y más efectivo
const secureWrapper = `
import sys
import time
-import threading
+import signal
from io import StringIO
-# Límites de seguridad
+# Configuración de límites
MAX_OUTPUT_LINES = ${this.maxOutputLines}
-MAX_MEMORY_MB = ${this.memoryLimitMB}
TIMEOUT_SECONDS = ${this.timeoutSeconds}
# Variables de control
_output_lines = 0
_start_time = time.time()
-_original_stdout = sys.stdout
-_string_io = StringIO()
_execution_stopped = False
+_original_stdout = sys.stdout
-class LimitedStringIO(StringIO):
+class SecurityStringIO(StringIO):
def write(self, s):
global _output_lines, _execution_stopped
+
if _execution_stopped:
- return
+ return len(s)
+
+ # Verificar timeout
+ if time.time() - _start_time > TIMEOUT_SECONDS:
+ raise TimeoutError("TIMEOUT_EXCEEDED")
# Contar líneas
_output_lines += s.count('\\n')
if _output_lines > MAX_OUTPUT_LINES:
- _execution_stopped = True
- super().write(f"\\n\\n Límite de output excedido ({MAX_OUTPUT_LINES} líneas)\\n")
- raise Exception("Output limit exceeded")
-
- # Verificar tiempo
- if time.time() - _start_time > TIMEOUT_SECONDS:
- _execution_stopped = True
- super().write(f"\\n\\n Tiempo de ejecución excedido ({TIMEOUT_SECONDS}s)\\n")
- raise Exception("Timeout exceeded")
+ raise RuntimeError("OUTPUT_LIMIT_EXCEEDED")
return super().write(s)
-# Reemplazar stdout
-sys.stdout = LimitedStringIO()
-
-# Función de trace para monitorear ejecución línea por línea
-def trace_calls(frame, event, arg):
- global _execution_stopped
- if _execution_stopped:
- raise Exception("Execution stopped")
-
- # Verificar timeout en cada línea
+# Función trace para detectar bucles infinitos
+def trace_execution(frame, event, arg):
if time.time() - _start_time > TIMEOUT_SECONDS:
- _execution_stopped = True
- raise Exception("Timeout exceeded")
-
- return trace_calls
+ raise TimeoutError("TIMEOUT_EXCEEDED")
+ return trace_execution
+
+# Configurar captura de salida
+sys.stdout = SecurityStringIO()
-# Activar trace
-sys.settrace(trace_calls)
+# Activar trazado
+sys.settrace(trace_execution)
try:
- # Ejecutar código del usuario
+ # Código del usuario
${code.split('\n').map(line => ' ' + line).join('\n')}
-
-except KeyboardInterrupt:
- print("\\n\\n Ejecución interrumpida")
-except Exception as e:
- if "Timeout exceeded" in str(e):
- print(f"\\n\\n Tiempo de ejecución excedido ({TIMEOUT_SECONDS}s)")
- elif "Output limit exceeded" in str(e):
- print(f"\\n\\n Límite de output excedido ({MAX_OUTPUT_LINES} líneas)")
- elif "Execution stopped" in str(e):
- print("\\n\\n Ejecución detenida")
- else:
- print(f"\\n\\nError: {e}")
+
+except TimeoutError as e:
+ if "TIMEOUT_EXCEEDED" in str(e):
+ raise Exception("TIMEOUT_EXCEEDED")
+ raise
+except RuntimeError as e:
+ if "OUTPUT_LIMIT_EXCEEDED" in str(e):
+ raise Exception("OUTPUT_LIMIT_EXCEEDED")
+ raise
+except MemoryError:
+ raise Exception("MEMORY_LIMIT_EXCEEDED")
finally:
- # Limpiar
sys.settrace(None)
-
- # Obtener output
- output_content = sys.stdout.getvalue() if hasattr(sys.stdout, 'getvalue') else ''
-
- # Restaurar stdout
- sys.stdout = _original_stdout
-
- # Imprimir resultado
- print(output_content, end='')
+ if not _execution_stopped:
+ output_content = sys.stdout.getvalue()
+ sys.stdout = _original_stdout
+ if output_content:
+ print(output_content, end='')
`;
return secureWrapper;
}
@@ -525,25 +504,29 @@ finally:
// Método para detener ejecución
stopExecution(): void {
- if (this.executionWorker) {
- this.executionWorker.terminate();
- this.executionWorker = null;
- }
if (this.executionTimeoutHandle) {
clearTimeout(this.executionTimeoutHandle);
this.executionTimeoutHandle = null;
}
this.isExecuting = false;
- this.output += '\n\n⏹️ Ejecución detenida por el usuario';
+ this.output = 'Ejecución detenida por el usuario';
this.codeOutput.emit(this.output);
}
- // Método privado para ejecutar código Python con límites estrictos
+ // Método principal para ejecutar código con límites efectivos
private async executeCodeWithLimits(code: string): Promise {
- return new Promise((resolve, reject) => {
+ return new Promise((resolve) => {
this.isExecuting = true;
+ this.executionStartTime = Date.now();
let output = '';
+ // Timeout de JavaScript como respaldo
+ this.executionTimeoutHandle = setTimeout(() => {
+ this.isExecuting = false;
+ this.executionTimeout.emit();
+ resolve('Se excedió el tiempo de ejecución');
+ }, this.timeoutSeconds * 1000);
+
// Configurar captura de stdout
this.pyodide.setStdout({
batched: (text: string) => {
@@ -559,13 +542,6 @@ finally:
// Crear código seguro
const secureCode = this.createSecurePythonCode(code);
- // Timeout de JavaScript como respaldo
- this.executionTimeoutHandle = setTimeout(() => {
- this.isExecuting = false;
- this.executionTimeout.emit();
- reject(new Error(`⏱️ Timeout de JavaScript: excedió ${this.timeoutSeconds} segundos`));
- }, this.timeoutSeconds * 1000);
-
// Ejecutar código
this.pyodide.runPythonAsync(secureCode)
.then(() => {
@@ -579,22 +555,68 @@ finally:
const errorMessage = error.message || error.toString();
- if (errorMessage.includes('Timeout exceeded') || errorMessage.includes('timeout')) {
+ // Manejo específico de límites
+ if (errorMessage.includes('TIMEOUT_EXCEEDED')) {
this.executionTimeout.emit();
- reject(new Error(`⏱️ Tiempo de ejecución excedido (${this.timeoutSeconds}s)`));
- } else if (errorMessage.includes('Output limit exceeded')) {
+ resolve('Se excedió el tiempo de ejecución');
+ } else if (errorMessage.includes('OUTPUT_LIMIT_EXCEEDED')) {
this.outputLimitExceeded.emit();
- reject(new Error(`⚠️ Límite de output excedido (${this.maxOutputLines} líneas)`));
- } else if (errorMessage.includes('Memory limit') || errorMessage.includes('MemoryError')) {
+ resolve('Se excedió el límite de salida');
+ } else if (errorMessage.includes('MEMORY_LIMIT_EXCEEDED')) {
this.memoryExceeded.emit();
- reject(new Error(`💾 Límite de memoria excedido (${this.memoryLimitMB}MB)`));
+ resolve('Se excedió el límite de memoria');
} else {
- reject(error);
+ // Error normal del código del usuario
+ if (output.trim()) {
+ resolve(`${output}\nError: ${this.extractUserFriendlyError(errorMessage)}`);
+ } else {
+ resolve(`Error: ${this.extractUserFriendlyError(errorMessage)}`);
+ }
}
});
});
}
+ private extractUserFriendlyError(errorMessage: string): string {
+ // Extraer el error más relevante del traceback
+ const lines = errorMessage.split('\n');
+
+ // Buscar la línea que contiene el error real
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const line = lines[i].trim();
+ if (line && !line.startsWith('File ') && !line.startsWith('Traceback') &&
+ !line.includes('During handling') && line !== '^') {
+ // Limpiar errores comunes
+ if (line.includes('SyntaxError:')) {
+ return line.replace('SyntaxError:', 'Error de sintaxis:');
+ } else if (line.includes('NameError:')) {
+ return line.replace('NameError:', 'Error de nombre:');
+ } else if (line.includes('TypeError:')) {
+ return line.replace('TypeError:', 'Error de tipo:');
+ } else if (line.includes('ValueError:')) {
+ return line.replace('ValueError:', 'Error de valor:');
+ } else if (line.includes('IndentationError:')) {
+ return line.replace('IndentationError:', 'Error de indentación:');
+ } else if (line.includes('ZeroDivisionError:')) {
+ return 'Error: División por cero';
+ } else if (line.includes('IndexError:')) {
+ return line.replace('IndexError:', 'Error de índice:');
+ } else if (line.includes('KeyError:')) {
+ return line.replace('KeyError:', 'Error de clave:');
+ }
+ return line;
+ }
+ }
+
+ // Si no encontramos nada específico, devolver el mensaje original limpio
+ return errorMessage.split('\n').filter(line =>
+ line.trim() &&
+ !line.includes('File ') &&
+ !line.includes('Traceback') &&
+ !line.includes('During handling')
+ ).pop() || 'Error desconocido';
+ }
+
async ejecutarCodigo(): Promise {
if (!this.pyodide) {
this.output = 'Pyodide no está cargado correctamente.';
@@ -607,6 +629,12 @@ finally:
return;
}
+ if (!this.codigo.trim()) {
+ this.output = 'No hay código para ejecutar.';
+ this.codeOutput.emit(this.output);
+ return;
+ }
+
try {
this.output = '';
this.output = await this.executeCodeWithLimits(this.codigo);
@@ -617,13 +645,13 @@ finally:
} catch (error: any) {
const errorMessage = String(error.message || error);
- this.output = `Error: ${errorMessage}`;
+ this.output = `Error inesperado: ${errorMessage}`;
this.codeOutput.emit(this.output);
this.codeExecuted.emit({code: this.codigo, output: this.output});
}
}
- // Nueva función para verificar solución (modo actividad) con límites
+ // Función para verificar solución (modo actividad) con límites
async verificarSolucion(): Promise {
if (!this.correctSolution) {
console.warn('No se ha proporcionado una solución correcta');
@@ -642,12 +670,31 @@ finally:
const userOutput = await this.executeCodeWithLimits(this.codigo);
this.output = userOutput;
+ // Si el output contiene mensajes de límite excedido, marcar como incorrecto
+ const isLimitExceeded = userOutput.includes('Se excedió el tiempo') ||
+ userOutput.includes('Se excedió el límite') ||
+ userOutput.includes('Ejecución detenida');
+
+ if (isLimitExceeded) {
+ this.codeOutput.emit(this.output);
+ this.solutionCheck.emit({correct: false, output: this.output});
+ return;
+ }
+
// Ejecutar la solución correcta (también con límites por seguridad)
const correctOutput = await this.executeCodeWithLimits(this.correctSolution);
// Comparar salidas (normalizar espacios en blanco)
- const normalizeOutput = (str: string) => str.trim().replace(/\s+/g, ' ');
- const isCorrect = normalizeOutput(userOutput) === normalizeOutput(correctOutput);
+ const normalizeOutput = (str: string) => {
+ return str.trim()
+ .replace(/\s+/g, ' ')
+ .replace(/\n\s*\n/g, '\n'); // Normalizar saltos de línea múltiples
+ };
+
+ const normalizedUserOutput = normalizeOutput(userOutput);
+ const normalizedCorrectOutput = normalizeOutput(correctOutput);
+
+ const isCorrect = normalizedUserOutput === normalizedCorrectOutput;
// Emitir eventos
this.codeOutput.emit(this.output);
diff --git a/frontend/angular/src/app/shared/navbar/navbar.component.html b/frontend/angular/src/app/shared/navbar/navbar.component.html
index 43db559..baf4a62 100644
--- a/frontend/angular/src/app/shared/navbar/navbar.component.html
+++ b/frontend/angular/src/app/shared/navbar/navbar.component.html
@@ -62,7 +62,7 @@ PyBloom
routerLink="/crear-curso"
routerLinkActive="active"
(click)="closeMenu()"
- >{{ "Crear cursos" | translate }}{{ "navbar.createCourse" | translate }}