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.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 @@ +
+ +
+

📚 Información del Curso

+
+ + +
+ El título es requerido +
+
+ +
+ + +
+ La descripción es requerida +
+
+
+ + +
+
+

📖 Contenidos del Curso

+ +
+ +
+
+ +
+
+ {{ i + 1 }} +

📄 Contenido {{ i + 1 }}

+
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+ {{ getResourceConsumptionError(i) }} +
+
+ +
+ + +
+ {{ getProcessingTimeError(i) }} +
+
+
+ + +
+
+

📋 Subcontenidos

+ +
+ +
+
+ +
+
+ {{ i + 1 }}.{{ j + 1 }} + 📝 Subcontenido {{ j + 1 }} +
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+
💻 Ejemplos de Código
+ +
+ +
+
+ +
+ {{ i + 1 }}.{{ j + 1 }}.{{ k + 1 }} + + +
+ +
+ +
+ + +
+ El código del ejemplo es requerido +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+

Vista Previa del JSON

+
{{ getPreviewJson() }}
+
+
\ 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

-
- -
-

📚 Información del Curso

-
- - -
- El título es requerido -
-
- -
- - -
- La descripción es requerida -
-
-
- - -
-
-

📖 Contenidos del Curso

- -
- -
-
- -
-
- {{ i + 1 }} -

📄 Contenido {{ i + 1 }}

-
- -
- -
-
- - -
- -
- - -
- - -
-
-

📋 Subcontenidos

- -
- -
-
- -
-
- {{ i + 1 }}.{{ j + 1 }} - 📝 Subcontenido {{ j + 1 }} -
- -
- -
-
- - -
- -
- - -
- - -
-
-
💻 Ejemplos de Código
- -
- -
-
-
- {{ i + 1 }}.{{ j + 1 }}.{{ k + 1 }} - - -
- -
-
-
-
-
-
-
-
-
-
-
- - -
- - -
-
- - -
-

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 }}