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..cc7b5cf --- /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..96c0a12 --- /dev/null +++ b/frontend/angular/src/app/components/form-course/form-course.component.spec.ts @@ -0,0 +1,24 @@ +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], + declarations: [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..960ef13 --- /dev/null +++ b/frontend/angular/src/app/components/form-course/form-course.component.ts @@ -0,0 +1,411 @@ +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'; + +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: any) { + 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..bc88af5 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,29 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +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: [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..fa3cd40 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,9 @@ -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'; +// Interfaces para el tipado de datos interface CourseData { course: { title: string; @@ -18,31 +17,19 @@ 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 { - title: string; - paragraph: string; - subcontent: FormSubcontentValue[]; -} - -interface FormSubcontentValue { - subtitle: string; - subparagraph: string; - example: string[]; -} - -interface FormValue { - title: string; - description: string; - contents: FormContentValue[]; +interface ExampleData { + code: string; } @Component({ @@ -50,257 +37,87 @@ 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; + currentCourseData: CourseData | null = null; - constructor( - private fb: FormBuilder, - private router: Router, - private coursesService: CoursesService - ) { - this.courseForm = this.createCourseForm(); - } + constructor(private router: Router) {} - 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; } - private createCourseForm(): FormGroup { - return this.fb.group({ - title: ['', [Validators.required, Validators.minLength(3)]], - description: ['', [Validators.required, Validators.minLength(10)]], - contents: this.fb.array([]) - }); - } - - // Getters para FormArrays - get contents(): FormArray { - return this.courseForm.get('contents') as FormArray; + // Manejar cambios en la validez del formulario + onFormValidChange(isValid: boolean): void { + this.isFormValid = isValid; } - getSubcontents(contentIndex: number): FormArray { - return this.contents.at(contentIndex).get('subcontent') as FormArray; + // Alternar vista previa del JSON + togglePreview(): void { + this.showPreview = !this.showPreview; } - getExamples(contentIndex: number, subcontentIndex: number): FormArray { - return this.getSubcontents(contentIndex).at(subcontentIndex).get('example') as FormArray; - } + // Enviar formulario + async onSubmit(): Promise { + if (!this.isFormValid || !this.currentCourseData) { + console.error('Formulario inválido o sin datos'); + return; + } - // 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([]) - }); + this.isSubmitting = true; - this.contents.push(contentGroup); + try { + // Simular una llamada asíncrona + await this.saveCourse(this.currentCourseData); - // 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(); - } - } - - 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 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); + } catch (error) { + console.error('Error al crear el curso:', error); + alert('Error al crear el curso. Por favor, inténtalo de nuevo.'); + } finally { + this.isSubmitting = 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); - } - } - - // 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(); + private async saveCourse(courseData: CourseData): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simular éxito o error + const success = Math.random() > 0.1; + + if (success) { + resolve(); + } else { + reject(new Error('Error simulado en el servidor')); } - } - - return contentData; + }, 2000); // Simular 2 segundos de carga }); - - 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; - } - - // 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`); - 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`); - return false; - } - } - } - - 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 - 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; - }, - error: (error) => { - console.error('Error al crear el curso:', error); - alert('Error al crear el curso: ' + error.message); - this.isSubmitting = false; - } - }); - - } 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; + } } - } - // 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(); - } - }); + this.router.navigate(['/cursos']); } - // 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étodo para obtener datos del curso pa debugging + getCourseData(): CourseData | null { + return this.currentCourseData; } } \ 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..35e928b --- /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 { 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], + 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..fb7ab38 --- /dev/null +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts @@ -0,0 +1,273 @@ +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'; + +// Interfaces para el tipado de datos +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; +} + +@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 + ) {} + + 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'); + } + + // Cargar datos del curso + this.initialCourseData = await this.fetchCourseData(this.courseId); + 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: string): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simular éxito o error + const success = Math.random() > 0.1; + + if (success) { + // Mock data basado en el formato JSON + const mockCourseData: CourseData = { + course: { + title: "afsafasfa", + description: "safagsgsgsaggas", + goto: "afsafasfa" + }, + contents: [ + { + title: "agsgasagsgsag", + paragraph: [ + "asgasgaasg" + ], + subcontent: [ + { + subtitle: "afavvqeqveqve", + subparagraph: [ + "qvevqevqeqveqve" + ], + example: [ + { + code: "142124dv" + }, + { + code: "dsvsdvdvsd112215" + } + ] + } + ], + next: "fqefqffqw", + maxResourceConsumption: 123, + maxProcessingTime: 421 + }, + { + title: "fqefqffqw", + paragraph: [ + "12wfqfeveqvqqve" + ], + subcontent: [ + { + subtitle: "qwrwqrqwr", + subparagraph: [ + "qwrwrqqwr" + ], + example: [ + { + code: "qwrqw" + } + ] + } + ], + next: null, + maxResourceConsumption: 124, + maxProcessingTime: 531 + } + ] + }; + resolve(mockCourseData); + } else { + reject(new Error('No se pudo cargar el curso')); + } + }, 1500); // Simular 1.5 segundos de carga + }); + } + + // 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; + } + + // Enviar formulario + async onSubmit(): Promise { + if (!this.isFormValid || !this.currentCourseData || !this.hasChanges) { + console.error('Formulario inválido, sin datos o sin cambios'); + return; + } + + this.isSubmitting = true; + + try { + // Simular una llamada asíncrona + await this.updateCourse(this.courseId!, this.currentCourseData); + + // 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); + alert('Error al actualizar el curso. Por favor, inténtalo de nuevo.'); + } finally { + this.isSubmitting = false; + } + } + + // Simular actualización del curso + private async updateCourse(courseId: string, courseData: CourseData): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simular éxito o error + const success = Math.random() > 0.1; + + if (success) { + resolve(); + } else { + reject(new Error('Error simulado en el servidor')); + } + }, 2000); // Simular 2 segundos de carga + }); + } + + // Cancelar edición + onCancel(): void { + if (this.hasChanges) { + const confirmLeave = confirm('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios no guardados.'); + if (!confirmLeave) { + return; + } + } + + this.router.navigate(['/cursos']); + } + + // Reintentar carga de datos + onRetry(): void { + this.loadCourseData(); + } + + // Método para obtener datos del curso pa debugging + getCourseData(): CourseData | undefined { + return this.currentCourseData; + } + + // Verificar si hay cambios pendientes + get hasPendingChanges(): boolean { + return this.hasChanges; + } + + // Obtener estado de carga + get isLoadingData(): boolean { + return this.isLoading; + } + + // Obtener estado de error + get hasLoadingError(): boolean { + return this.hasError; + } +} \ No newline at end of file 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 }}