diff --git a/.github/workflows/validate-submission.yml b/.github/workflows/validate-submission.yml new file mode 100644 index 0000000..a04aed0 --- /dev/null +++ b/.github/workflows/validate-submission.yml @@ -0,0 +1,260 @@ +name: Validate Java Exercise + +on: + repository_dispatch: + types: [validate_submission] + +jobs: + test-and-report: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup Java 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + + - name: Display received payload + run: | + echo "Submission ID: ${{ github.event.client_payload.submissionId }}" + echo "Exercise ID: ${{ github.event.client_payload.exerciseId }}" + echo "User ID: ${{ github.event.client_payload.userId }}" + + - name: Fetch exercise test code from Firestore + id: fetch_test + env: + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + run: | + echo "📥 Obteniendo código de test desde Firestore..." + echo "🔑 Project ID: $FIREBASE_PROJECT_ID" + echo "📝 Exercise ID: ${{ github.event.client_payload.exerciseId }}" + + EXERCISE_RESPONSE=$(curl -s \ + "https://firestore.googleapis.com/v1/projects/$FIREBASE_PROJECT_ID/databases/(default)/documents/exercises/${{ github.event.client_payload.exerciseId }}") + + # Debug: mostrar respuesta completa + echo "🔍 Respuesta de Firestore (primeros 500 caracteres):" + echo "$EXERCISE_RESPONSE" | head -c 500 + echo "" + echo "---" + + # Verificar si hay error en la respuesta + ERROR_CODE=$(echo "$EXERCISE_RESPONSE" | jq -r '.error.code // empty') + if [ ! -z "$ERROR_CODE" ]; then + echo "❌ Error de Firestore: $ERROR_CODE" + echo "$EXERCISE_RESPONSE" | jq '.error' + exit 1 + fi + + # Extract testCode field from Firestore response + TEST_CODE=$(echo "$EXERCISE_RESPONSE" | jq -r '.fields.testCode.stringValue // empty') + + echo "📏 Longitud del testCode: ${#TEST_CODE}" + + # Si no hay testCode, intentar con tests (viejo formato) + if [ -z "$TEST_CODE" ]; then + echo "⚠️ No se encontró campo 'testCode'" + echo "🔍 Campos disponibles en el documento:" + echo "$EXERCISE_RESPONSE" | jq '.fields | keys' + + # Usar el test por defecto que ya existe en el repo + if [ ! -f "example/actions-ejemplo/src/test/java/com/javatutor/AppTest.java" ]; then + echo "❌ No se encontró código de test para este ejercicio" + exit 1 + fi + echo "✅ Usando test existente en el repositorio" + else + # Create test directory + mkdir -p example/actions-ejemplo/src/test/java/com/javatutor + + # Write test code to AppTest.java + echo "$TEST_CODE"> example/actions-ejemplo/src/test/java/com/javatutor/AppTest.java + + echo "✅ Test descargado y guardado desde Firestore" + fi + + echo "📄 Contenido del test:" + cat example/actions-ejemplo/src/test/java/com/javatutor/AppTest.java + + - name: Verify project structure + run: | + echo "📂 Verificando estructura del proyecto..." + ls -la example/actions-ejemplo/ || echo "❌ No existe example/actions-ejemplo/" + ls -la example/actions-ejemplo/build.gradle || echo "❌ No existe build.gradle" + ls -la example/actions-ejemplo/src/test/java/com/javatutor/ || echo "❌ No existen tests" + + - name: Create student code file + run: | + echo "📝 Creando archivo con código del estudiante..." + + # Borrar App.java existente para evitar conflictos + rm -f example/actions-ejemplo/src/main/java/com/javatutor/App.java + + # Crear directorio + mkdir -p example/actions-ejemplo/src/main/java/com/javatutor + + # Guardar el código del estudiante + cat> example/actions-ejemplo/src/main/java/com/javatutor/App.java << 'EOFCODE' + ${{ github.event.client_payload.studentCode }} + EOFCODE + + echo "✅ Código del estudiante guardado:" + cat example/actions-ejemplo/src/main/java/com/javatutor/App.java + echo "📏 Número de líneas:" + wc -l example/actions-ejemplo/src/main/java/com/javatutor/App.java + + - name: Verify files before compilation + run: | + echo "🔍 Verificando archivos antes de compilar..." + echo "📁 Archivos en src/main/java/com/javatutor/:" + ls -la example/actions-ejemplo/src/main/java/com/javatutor/ + echo "📁 Archivos en src/test/java/com/javatutor/:" + ls -la example/actions-ejemplo/src/test/java/com/javatutor/ + echo "📄 Contenido de App.java:" + cat example/actions-ejemplo/src/main/java/com/javatutor/App.java + + - name: Run Maven tests + id: maven_test + continue-on-error: true + working-directory: example/actions-ejemplo + run: | + echo "🧪 Ejecutando tests con Maven..." + + # Ejecutar tests con flags de optimización + mvn test -B -q -T 1C \ + -Dmaven.test.failure.ignore=false \ + -Dmaven.javadoc.skip=true \ + -Drat.skip=true \ + -Dcheckstyle.skip=true \ + -Denforcer.skip=true + echo "exit_code=$?">> $GITHUB_OUTPUT + + echo "📁 Archivos generados por Maven:" + ls -la target/surefire-reports/ 2>/dev/null || echo "⚠️ No se generaron resultados de tests" + + - name: Parse test results + id: parse_results + run: | + echo "🔍 Parseando resultados de tests..." + + # Buscar archivo XML de Maven + XML_FILE=$(find example/actions-ejemplo/target/surefire-reports -name "*.xml" -type f 2>/dev/null | head -n 1) + + if [ -f "$XML_FILE" ]; then + echo "✅ Archivo XML encontrado: $XML_FILE" + + # Usar grep/sed en lugar de xmllint (más rápido, no requiere instalar paquetes) + TESTS_RUN=$(grep -oP 'tests="\K[^"]+' "$XML_FILE" | head -1) + TESTS_FAILED=$(grep -oP 'failures="\K[^"]+' "$XML_FILE" | head -1) + TESTS_ERRORS=$(grep -oP 'errors="\K[^"]+' "$XML_FILE" | head -1) + TESTS_SKIPPED=$(grep -oP 'skipped="\K[^"]+' "$XML_FILE" | head -1) + + # Valores por defecto si están vacíos + TESTS_RUN=${TESTS_RUN:-0} + TESTS_FAILED=${TESTS_FAILED:-0} + TESTS_ERRORS=${TESTS_ERRORS:-0} + TESTS_SKIPPED=${TESTS_SKIPPED:-0} + + # Calcular tests pasados + TESTS_PASSED=$((TESTS_RUN - TESTS_FAILED - TESTS_ERRORS - TESTS_SKIPPED)) + + echo "📊 Resultados extraídos del XML:" + echo " Tests run: $TESTS_RUN" + echo " Tests passed: $TESTS_PASSED" + echo " Tests failed: $TESTS_FAILED" + echo " Tests errors: $TESTS_ERRORS" + + echo "tests_run=$TESTS_RUN">> $GITHUB_OUTPUT + echo "tests_passed=$TESTS_PASSED">> $GITHUB_OUTPUT + echo "tests_failed=$TESTS_FAILED">> $GITHUB_OUTPUT + + # Determinar status + if [ "$TESTS_FAILED" -eq "0" ] && [ "$TESTS_ERRORS" -eq "0" ] && [ "$TESTS_RUN" -gt "0" ]; then + echo "status=success">> $GITHUB_OUTPUT + echo "error_report=">> $GITHUB_OUTPUT + echo "✅ Todos los tests pasaron" + elif [ "$TESTS_RUN" -eq "0" ]; then + echo "status=error">> $GITHUB_OUTPUT + echo "error_report=No se ejecutaron tests">> $GITHUB_OUTPUT + echo "⚠️ No se ejecutaron tests" + else + echo "status=failed">> $GITHUB_OUTPUT + + # Extraer mensaje de error del XML (usando grep/sed, más rápido) + ERROR_MSG=$(grep -oP ']*>\K[^<]+' "$XML_FILE" 2>/dev/null | head -1) + if [ -z "$ERROR_MSG" ]; then + ERROR_MSG=$(grep -oP ']*>\K[^<]+' "$XML_FILE" 2>/dev/null | head -1) + fi + if [ -z "$ERROR_MSG" ]; then + ERROR_MSG="Tests fallaron sin mensaje de error" + fi + + # Limitar longitud del error + ERROR_MSG="${ERROR_MSG:0:500}" + ERROR_MSG=$(echo "$ERROR_MSG" | sed 's/"/\\"/g' | tr '\n' ' ') + + echo "error_report=$ERROR_MSG">> $GITHUB_OUTPUT + echo "❌ Tests fallaron: $ERROR_MSG" + fi + + else + # Si no hay XML, asumir que el exit code indica el resultado + echo "⚠️ XML no encontrado, usando exit code de Maven" + + EXIT_CODE="${{ steps.maven_test.outputs.exit_code }}" + if [ "$EXIT_CODE" = "0" ]; then + echo "status=success">> $GITHUB_OUTPUT + echo "tests_run=1">> $GITHUB_OUTPUT + echo "tests_passed=1">> $GITHUB_OUTPUT + echo "tests_failed=0">> $GITHUB_OUTPUT + echo "error_report=">> $GITHUB_OUTPUT + else + echo "status=failed">> $GITHUB_OUTPUT + echo "tests_run=1">> $GITHUB_OUTPUT + echo "tests_passed=0">> $GITHUB_OUTPUT + echo "tests_failed=1">> $GITHUB_OUTPUT + echo "error_report=Test execution failed">> $GITHUB_OUTPUT + fi + fi + + - name: Report results to Firestore + env: + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + run: | + JSON_PAYLOAD=$(cat < + + 4.0.0 + + com.javatutor + actions-ejemplo + 1.0-SNAPSHOT + jar + + Java Tutor - Actions Ejemplo + Proyecto de ejemplo para validación de ejercicios con GitHub Actions + + + UTF-8 + 11 + 11 + 5.9.3 + + + false + true + true + true + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 11 + 11 + false + false + false + false + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0 + + + **/*Test.java + + methods + 2 + 1 + true +
true + true + false + plain + + + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.1 + + true + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.1 + + true + + + + + diff --git a/example/actions-ejemplo/src/main/java/com/javatutor/App.class b/example/actions-ejemplo/src/main/java/com/javatutor/App.class new file mode 100644 index 0000000..5ce1e83 Binary files /dev/null and b/example/actions-ejemplo/src/main/java/com/javatutor/App.class differ diff --git a/example/actions-ejemplo/src/main/java/com/javatutor/App.java b/example/actions-ejemplo/src/main/java/com/javatutor/App.java new file mode 100644 index 0000000..c5b858a --- /dev/null +++ b/example/actions-ejemplo/src/main/java/com/javatutor/App.java @@ -0,0 +1,7 @@ +package com.javatutor; + +public class App { + public static void main(String[] args) { + System.out.println("Hola Mundo"); + } +} diff --git a/example/actions-ejemplo/src/test/java/com/javatutor/AppTest.java b/example/actions-ejemplo/src/test/java/com/javatutor/AppTest.java new file mode 100644 index 0000000..24db435 --- /dev/null +++ b/example/actions-ejemplo/src/test/java/com/javatutor/AppTest.java @@ -0,0 +1,34 @@ +package com.javatutor; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +class AppTest { + private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); + private final PrintStream standardOut = System.out; + + @BeforeEach + public void setUp() { + System.setOut(new PrintStream(outputStreamCaptor)); + } + + @AfterEach + public void tearDown() { + System.setOut(standardOut); + } + + @Test + void testHolaMundo() { + // Ejecutar el main + App.main(new String[]{}); + + // Verificar que imprima "Hola Mundo" + String output = outputStreamCaptor.toString().trim(); + assertEquals("Hola Mundo", output, "El programa debe imprimir exactamente 'Hola Mundo'"); + } +} diff --git a/example/actions-ejemplo/target/test-classes/com/javatutor/AppTest.class b/example/actions-ejemplo/target/test-classes/com/javatutor/AppTest.class new file mode 100644 index 0000000..190cfd4 Binary files /dev/null and b/example/actions-ejemplo/target/test-classes/com/javatutor/AppTest.class differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..8b3795e --- /dev/null +++ b/index.html @@ -0,0 +1,55 @@ + + + + + + + + Java Tutor - Bienvenido + + + + + + + + +
+ + +
+ + + + + + + + +
+

+ Bienvenido + a Java Tutor. +

+ +

+ Tu dashboard personal para seguir tu progreso en el curso de Java. +

+
+ +
+ + +

AltStyle によって変換されたページ (->オリジナル) /

+ + \ No newline at end of file diff --git a/login.html b/login.html deleted file mode 100644 index e69de29..0000000 diff --git a/src/assets/icons/icon.gif b/src/assets/icons/icon.gif new file mode 100644 index 0000000..3bbe878 Binary files /dev/null and b/src/assets/icons/icon.gif differ diff --git a/src/assets/icons/icon_java.apng b/src/assets/icons/icon_java.apng new file mode 100644 index 0000000..a062e59 Binary files /dev/null and b/src/assets/icons/icon_java.apng differ diff --git a/src/assets/icons/main_icon.png b/src/assets/icons/main_icon.png new file mode 100644 index 0000000..49d0d65 Binary files /dev/null and b/src/assets/icons/main_icon.png differ diff --git a/src/assets/one.jpg b/src/assets/one.jpg new file mode 100644 index 0000000..36325c8 Binary files /dev/null and b/src/assets/one.jpg differ diff --git a/src/css/admin.css b/src/css/admin.css new file mode 100644 index 0000000..78af80d --- /dev/null +++ b/src/css/admin.css @@ -0,0 +1,1438 @@ +/** + * admin.css - Estilos para el Panel de Administración + * Sistema completo de gestión de ejercicios y usuarios + */ + +/* ================================ + VARIABLES Y RESET +================================ */ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-card: #1e293b; + --bg-hover: #334155; + + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --text-tertiary: #94a3b8; + + --accent-color: #3b82f6; + --accent-light: #60a5fa; + --accent-dark: #2563eb; + + --success-color: #22c55e; + --warning-color: #f59e0b; + --danger-color: #ef4444; + + --border-color: #334155; + --border-light: #475569; + + --sidebar-width: 280px; + --header-height: 80px; + + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + overflow-x: hidden; +} + +/* ================================ + LAYOUT +================================ */ +.admin-container { + display: flex; + min-height: 100vh; +} + +/* ================================ + SIDEBAR +================================ */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + position: fixed; + left: 0; + top: 0; + height: 100vh; + transition: var(--transition); + z-index: 1000; +} + +.sidebar-header { + padding: 24px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; + color: var(--accent-color); +} + +.logo i { + width: 28px; + height: 28px; +} + +.logo-text { + font-size: 18px; + font-weight: 700; + white-space: nowrap; + transition: var(--transition); +} + +/* ================================ + NAVIGATION +================================ */ +.sidebar-nav { + flex: 1; + padding: 20px 12px; + overflow-y: auto; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin-bottom: 8px; + border-radius: 10px; + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition); + cursor: pointer; + font-weight: 500; +} + +.nav-item i { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-item.active { + background: rgba(59, 130, 246, 0.15); + color: var(--accent-color); +} + +.nav-text { + white-space: nowrap; + transition: var(--transition); +} + +/* ================================ + SIDEBAR FOOTER +================================ */ +.sidebar-footer { + padding: 20px 12px; + border-top: 1px solid var(--border-color); +} + +.admin-profile { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + margin-top: 12px; + border-radius: 10px; + background: var(--bg-hover); +} + +.admin-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; +} + +.admin-info { + flex: 1; + min-width: 0; + transition: var(--transition); +} + +.admin-name { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-role { + font-size: 12px; + color: var(--text-tertiary); +} + +/* ================================ + MOBILE SIDEBAR +================================ */ +.mobile-sidebar-toggle { + display: none; + position: fixed; + top: 20px; + left: 20px; + z-index: 999; + background: var(--accent-color); + border: none; + color: white; + width: 44px; + height: 44px; + border-radius: 12px; + cursor: pointer; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-lg); +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 999; + opacity: 0; + transition: opacity 0.3s ease; +} + +.sidebar-overlay.active { + opacity: 1; +} + +@media (max-width: 1024px) { + .mobile-sidebar-toggle { + display: flex; + } + + .sidebar { + transform: translateX(-100%); + } + + .sidebar.collapsed { + transform: translateX(0); + width: var(--sidebar-width); + } + + .sidebar.collapsed .logo-text, + .sidebar.collapsed .nav-text, + .sidebar.collapsed .admin-info { + opacity: 1; + width: auto; + } + + .sidebar-overlay { + display: block; + } +} + +/* ================================ + MAIN CONTENT +================================ */ +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; + width: calc(100% - var(--sidebar-width)); +} + +@media (max-width: 1024px) { + .main-content { + margin-left: 0; + width: 100%; + } +} + +/* ================================ + HEADER +================================ */ +.admin-header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 24px 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.page-title { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.page-subtitle { + font-size: 14px; + color: var(--text-tertiary); +} + +.header-right { + display: flex; + gap: 12px; +} + +/* ================================ + BUTTONS +================================ */ +.btn { + padding: 12px 24px; + border: none; + border-radius: 10px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: var(--transition); + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; +} + +.btn i { + width: 18px; + height: 18px; +} + +.btn-primary { + background: var(--accent-color); + color: white; +} + +.btn-primary:hover { + background: var(--accent-dark); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: var(--bg-hover); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--border-light); +} + +.btn-danger { + background: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); +} + +.btn-sm { + padding: 8px 16px; + font-size: 13px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +/* ================================ + CONTENT SECTIONS +================================ */ +.content-section { + display: none; + padding: 40px; + animation: fadeIn 0.3s ease; +} + +.content-section.active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ================================ + ADMIN FILTERS BAR +================================ */ +.admin-filters-bar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + padding: 16px 20px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; +} + +.admin-filters-bar .search-box { + flex: 1; + position: relative; + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 0 16px; + transition: all 0.3s ease; +} + +.admin-filters-bar .search-box:focus-within { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.admin-filters-bar .search-box i { + width: 18px; + height: 18px; + color: var(--text-tertiary); +} + +.admin-filters-bar .search-box input { + flex: 1; + background: transparent; + border: none; + outline: none; + padding: 12px 0; + color: var(--text-primary); + font-size: 14px; +} + +.admin-filters-bar .search-box input::placeholder { + color: var(--text-tertiary); +} + +.admin-filters-bar .filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-filters-bar .filter-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.admin-filters-bar .filter-group label i { + width: 16px; + height: 16px; +} + +.admin-filters-bar .filter-select { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px 12px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + min-width: 180px; +} + +.admin-filters-bar .filter-select:hover { + border-color: var(--accent-color); +} + +.admin-filters-bar .filter-select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.admin-filters-bar .view-toggle { + display: flex; + gap: 4px; + background: var(--bg-secondary); + border-radius: 10px; + padding: 4px; + border: 1px solid var(--border-color); +} + +.admin-filters-bar .view-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.3s ease; +} + +.admin-filters-bar .view-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.admin-filters-bar .view-btn.active { + background: var(--accent-color); + color: white; +} + +.admin-filters-bar .view-btn i { + width: 18px; + height: 18px; +} + +/* ================================ + EXERCISES GRID +================================ */ +.exercises-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 24px; +} + +.exercise-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 24px; + transition: var(--transition); + cursor: pointer; +} + +.exercise-card:hover { + border-color: var(--accent-color); + transform: translateY(-4px); + box-shadow: 0 10px 30px rgba(59, 130, 246, 0.2); +} + +.exercise-card-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 16px; +} + +.exercise-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.exercise-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.badge { + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.badge.difficulty-easy { + background: rgba(34, 197, 94, 0.15); + color: var(--success-color); +} + +.badge.difficulty-medium { + background: rgba(245, 158, 11, 0.15); + color: var(--warning-color); +} + +.badge.difficulty-hard { + background: rgba(239, 68, 68, 0.15); + color: var(--danger-color); +} + +.badge.category { + background: rgba(59, 130, 246, 0.15); + color: var(--accent-color); +} + +.exercise-description { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 16px; + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + line-clamp: 3; + overflow: hidden; +} + +.exercise-author { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-tertiary); + font-size: 13px; + font-style: italic; + margin-bottom: 12px; +} + +.exercise-author i { + width: 14px; + height: 14px; +} + +.exercise-stats { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 16px; + border-top: 1px solid var(--border-color); + font-size: 13px; + color: var(--text-tertiary); +} + +.exercise-stats a { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent-purple); + text-decoration: none; + font-weight: 500; + transition: all 0.3s ease; +} + +.exercise-stats a:hover { + color: var(--accent-color); + transform: translateX(2px); +} + +.exercise-stats a i { + width: 14px; + height: 14px; +} + +.exercise-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.icon-btn { + background: var(--bg-hover); + border: none; + color: var(--text-secondary); + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--transition); +} + +.icon-btn:hover { + background: var(--accent-color); + color: white; +} + +.icon-btn.delete:hover { + background: var(--danger-color); +} + +/* List View for Admin */ +.exercises-grid.list-view { + display: flex; + flex-direction: column; + gap: 12px; +} + +.exercises-grid.list-view .exercise-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + cursor: default; + border-left: 3px solid transparent; +} + +.exercises-grid.list-view .exercise-card:hover { + border-left-color: var(--accent-color); +} + +.exercises-grid.list-view .exercise-card-header { + flex: 0 0 auto; + margin: 0; +} + +.exercises-grid.list-view .exercise-title { + margin: 0; + font-size: 15px; + min-width: 250px; + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exercises-grid.list-view .exercise-meta { + margin: 0; +} + +.exercises-grid.list-view .exercise-description { + display: none; +} + +.exercises-grid.list-view .exercise-author { + margin: 0; + flex: 0 0 auto; + font-size: 12px; +} + +.exercises-grid.list-view .exercise-stats { + flex: 1; + padding: 0; + border: none; + margin: 0; +} + +.exercises-grid.list-view .exercise-actions { + margin: 0; + flex: 0 0 auto; +} + +/* ================================ + SKELETON LOADERS +================================ */ +.skeleton-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 24px; + height: 250px; + position: relative; + overflow: hidden; +} + +.skeleton-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.05), + transparent + ); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +/* ================================ + MODAL +================================ */ +.modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 2000; + align-items: center; + justify-content: center; + padding: 20px; + overflow-y: auto; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: var(--bg-secondary); + border-radius: 20px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + animation: modalSlideIn 0.3s ease; +} + +.modal-large { + max-width: 900px; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + padding: 24px 32px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + font-size: 22px; + font-weight: 700; +} + +.modal-close { + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: var(--transition); +} + +.modal-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.modal-body { + padding: 32px; +} + +/* ================================ + FORMS +================================ */ +.form-section { + margin-bottom: 32px; + padding-bottom: 32px; + border-bottom: 1px solid var(--border-color); +} + +.form-section:last-of-type { + border-bottom: none; +} + +.form-section h3 { + font-size: 16px; + font-weight: 700; + margin-bottom: 20px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.form-section h3 svg { + width: 18px; + height: 18px; +} + +/* Solution Section - Admin Only */ +.solution-section { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(147, 51, 234, 0.05)); + border: 2px solid rgba(59, 130, 246, 0.2); + border-radius: 12px; + padding: 24px; + margin-bottom: 32px; +} + +.solution-section h3 { + color: var(--accent-light); +} + +.solution-section .help-text { + display: flex; + align-items: center; + gap: 6px; + background: rgba(59, 130, 246, 0.1); + padding: 8px 12px; + border-radius: 8px; + border-left: 3px solid var(--accent-color); +} + +.solution-section .help-text svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.form-group .help-text { + font-size: 13px; + color: var(--text-tertiary); + margin-bottom: 8px; + font-weight: 400; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-primary); + font-size: 14px; + font-family: inherit; + transition: var(--transition); +} + +.form-group textarea { + resize: vertical; + font-family: 'Fira Code', 'Courier New', monospace; + line-height: 1.5; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 24px; + border-top: 1px solid var(--border-color); +} + +/* ================================ + TEST ITEM (Dynamic) +================================ */ +.test-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; + position: relative; +} + +.test-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.test-item-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.test-item-remove { + background: transparent; + border: none; + color: var(--danger-color); + cursor: pointer; + padding: 6px; + border-radius: 6px; + transition: var(--transition); + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.test-item-remove:hover { + background: rgba(239, 68, 68, 0.15); +} + +/* ================================ + TABLES +================================ */ +.users-table { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 24px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +th { + text-align: left; + padding: 12px 16px; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-tertiary); + border-bottom: 2px solid var(--border-color); +} + +td { + padding: 16px; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover { + background: var(--bg-hover); +} + +td.loading { + text-align: center; + color: var(--text-tertiary); + padding: 40px; +} + +/* ================================ + STATS GRID +================================ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; + margin-top: 24px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 24px; + display: flex; + align-items: center; + gap: 20px; +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(59, 130, 246, 0.15); + color: var(--accent-color); +} + +.stat-info { + flex: 1; +} + +.stat-value { + font-size: 32px; + font-weight: 800; + color: var(--text-primary); + line-height: 1; + margin-bottom: 6px; +} + +.stat-label { + font-size: 14px; + color: var(--text-tertiary); +} + +/* ================================ + TOAST NOTIFICATIONS +================================ */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 3000; + display: flex; + flex-direction: column; + gap: 12px; +} + +.toast { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px 20px; + min-width: 300px; + box-shadow: var(--shadow-lg); + display: flex; + align-items: center; + gap: 12px; + animation: toastSlideIn 0.3s ease; +} + +@keyframes toastSlideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.toast.success { + border-left: 4px solid var(--success-color); +} + +.toast.error { + border-left: 4px solid var(--danger-color); +} + +.toast.info { + border-left: 4px solid var(--accent-color); +} + +.toast-icon { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.toast.success .toast-icon { + color: var(--success-color); +} + +.toast.error .toast-icon { + color: var(--danger-color); +} + +.toast.info .toast-icon { + color: var(--accent-color); +} + +.toast-content { + flex: 1; +} + +.toast-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 2px; +} + +.toast-message { + font-size: 13px; + color: var(--text-tertiary); +} + +/* ================================ + SUBMISSIONS SECTION +================================ */ +.submissions-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.submission-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + transition: var(--transition); +} + +.submission-card:hover { + border-color: var(--border-light); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.submission-card.success { + border-left: 4px solid var(--success-color); +} + +.submission-card.failure { + border-left: 4px solid var(--danger-color); +} + +.submission-card.pending { + border-left: 4px solid var(--warning-color); +} + +.submission-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.submission-user { + display: flex; + align-items: center; + gap: 12px; +} + +.submission-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--border-color); +} + +.submission-user-info { + display: flex; + flex-direction: column; +} + +.submission-user-name { + font-weight: 600; + color: var(--text-primary); + font-size: 14px; +} + +.submission-user-github { + font-size: 12px; + color: var(--text-tertiary); +} + +.submission-status { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.submission-status.status-success { + background: rgba(34, 197, 94, 0.1); + color: var(--success-color); +} + +.submission-status.status-failure { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color); +} + +.submission-status.status-pending { + background: rgba(245, 158, 11, 0.1); + color: var(--warning-color); +} + +.submission-status i { + width: 14px; + height: 14px; +} + +.submission-body { + margin-bottom: 16px; +} + +.submission-exercise { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: var(--text-primary); + font-weight: 500; +} + +.submission-exercise i { + width: 18px; + height: 18px; + color: var(--accent-color); +} + +.submission-meta { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.submission-date, +.submission-tests { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-tertiary); +} + +.submission-date i, +.submission-tests i { + width: 16px; + height: 16px; +} + +.submission-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.btn-icon { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 8px; + border-radius: 8px; + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.btn-icon:hover { + background: var(--bg-hover); + border-color: var(--accent-color); + color: var(--accent-color); + transform: translateY(-2px); +} + +.btn-icon i { + width: 18px; + height: 18px; +} + +.empty-state, +.error-state { + text-align: center; + padding: 60px 20px; + color: var(--text-tertiary); + font-size: 14px; +} + +.error-state { + color: var(--danger-color); +} + +.error-state i { + width: 48px; + height: 48px; + margin-bottom: 16px; +} + +.loading { + text-align: center; + padding: 40px; + color: var(--text-tertiary); +} + +/* ================================ + RESPONSIVE +================================ */ +@media (max-width: 768px) { + .admin-header { + flex-direction: column; + align-items: flex-start; + padding: 20px; + } + + .content-section { + padding: 20px; + } + + .exercises-grid { + grid-template-columns: 1fr; + } + + .modal-body { + padding: 20px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .toast-container { + left: 20px; + right: 20px; + } + + .toast { + min-width: auto; + } +} diff --git a/src/css/dashboard.css b/src/css/dashboard.css new file mode 100644 index 0000000..94f49ff --- /dev/null +++ b/src/css/dashboard.css @@ -0,0 +1,1236 @@ +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-color: #000; + --bg-secondary: #0d0d0d; + --bg-card: #1a1a1a; + --border-color: #2a2a2a; + --text-primary: #fff; + --text-secondary: #a0a0a0; + --text-tertiary: #666; + --accent-color: #3b82f6; /* Azul principal */ + --accent-light: #60a5fa; /* Azul claro */ + --accent-dark: #1e40af; /* Azul oscuro */ + --accent-yellow: #fbbf24; /* Amarillo */ + --accent-yellow-light: #fcd34d; /* Amarillo claro */ + --success-color: #22c55e; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --info-color: #3b82f6; + --sidebar-width: 280px; + --sidebar-width-collapsed: 80px; + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-color); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.dashboard-layout { + display: flex; + min-height: 100vh; + position: relative; +} + +/* Enhanced Scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg-color); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 5px; + transition: var(--transition); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-color); +} + +/* Enhanced Animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes skeletonLoading { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +/* Apply animation stagger */ +.widget { + animation: fadeInUp 0.7s ease-out backwards; +} +.widget:nth-child(1) { animation-delay: 0.1s; } +.widget:nth-child(2) { animation-delay: 0.15s; } +.widget:nth-child(3) { animation-delay: 0.2s; } +.widget:nth-child(4) { animation-delay: 0.25s; } +.widget:nth-child(5) { animation-delay: 0.3s; } +.widget:nth-child(6) { animation-delay: 0.35s; } + + +/* ================================= + Sidebar Enhanced (Menú Lateral) +================================= */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-color); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + /* Usar transition solo para width en desktop */ + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + position: relative; /* Cambiado de absolute/fixed por defecto */ + z-index: 100; + flex-shrink: 0; /* Evita que se encoja */ +} + +/* ... (pseudoelementos ::before y ::after sin cambios) ... */ +.sidebar::before { + content: ''; position: absolute; top: 0; right: 0; width: 1px; height: 100%; + background: linear-gradient(180deg, transparent 0%, var(--accent-color) 30%, var(--accent-yellow) 50%, var(--accent-color) 70%, transparent 100%); + opacity: 0.4; +} +.sidebar::after { + content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; + background: radial-gradient(circle, rgba(59, 130, 246, 0.03) 0%, transparent 70%); + pointer-events: none; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 26px 22px; + height: 85px; + min-height: 85px; /* Asegurar altura mínima */ + border-bottom: 1px solid var(--border-color); + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-color) 100%); + position: relative; /* Necesario para z-index si se solapa */ + flex-shrink: 0; /* Evita que se encoja */ + z-index: 10; /* Asegurar que esté encima */ +} + +.logo { + display: flex; align-items: center; gap: 14px; text-decoration: none; + color: var(--text-primary); transition: var(--transition); +} +.logo:hover { transform: translateX(3px); } +.logo-icon { + width: 38px; height: 38px; border-radius: 10px; + transition: var(--transition); +} +.logo:hover .logo-icon { + transform: scale(1.05); +} +.logo-text { + font-size: 21px; font-weight: 700; white-space: nowrap; opacity: 1; + transition: opacity 0.3s ease; letter-spacing: -0.5px; + background: linear-gradient(135deg, #ffffff 0%, #d1d5db 100%); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; +} + +.sidebar-toggle { /* Botón Desktop */ + background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-secondary); + cursor: pointer; padding: 10px; border-radius: 10px; transition: var(--transition); + display: flex; align-items: center; justify-content: center; + position: relative; /* Asegurar que permanezca en su posición */ + z-index: 10; /* Asegurar que esté encima de otros elementos */ +} +.sidebar-toggle:hover { + color: var(--text-primary); background: rgba(59, 130, 246, 0.1); + border-color: var(--accent-color); transform: scale(1.05); +} + +/* --- Menú Links Enhanced --- */ +.menu-links { + list-style: none; padding: 24px 0; flex-grow: 1; overflow-y: auto; +} +.menu-item a { + display: flex; align-items: center; gap: 16px; padding: 16px 22px; margin: 5px 14px; + color: var(--text-secondary); text-decoration: none; white-space: nowrap; + transition: var(--transition); border-radius: 12px; position: relative; + font-weight: 500; font-size: 15px; +} +.menu-item a::before { /* Indicador hover */ + content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 0; + background: linear-gradient(90deg, var(--accent-color), transparent); + border-radius: 12px 0 0 12px; transition: width 0.3s ease; +} +.menu-item a:hover { + color: var(--text-primary); background: rgba(255, 255, 255, 0.05); transform: translateX(5px); +} +.menu-item a:hover::before { width: 4px; } +.menu-item.active a { + color: var(--text-primary); font-weight: 700; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(251, 191, 36, 0.1) 100%); + border-left: 4px solid var(--accent-color); + box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2); +} +.menu-item.active a::after { /* Punto indicador activo */ + content: ''; position: absolute; right: 16px; top: 50%; transform: translateY(-50%); + width: 8px; height: 8px; background: var(--accent-color); border-radius: 50%; + box-shadow: 0 0 15px var(--accent-color); animation: pulse 2s ease-in-out infinite; +} +.menu-item a .link-text { opacity: 1; transition: opacity 0.3s ease; } +.menu-item a i { flex-shrink: 0; width: 18px; /* Ancho fijo para icono */ } + +/* Submenú Enhanced */ +.submenu-toggle { margin-left: auto; transition: transform 0.3s ease; } +.submenu { + list-style: none; padding-left: 52px; max-height: 0; overflow: hidden; + transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} +.menu-item.submenu-open .submenu { max-height: 600px; } +.menu-item.submenu-open .submenu-toggle { transform: rotate(90deg); color: var(--accent-color); } +.submenu-item a { padding: 12px 22px; font-size: 14px; margin: 3px 14px; } + +/* --- Footer del Sidebar Enhanced --- */ +.sidebar-footer { + padding: 22px; border-top: 1px solid var(--border-color); + background: linear-gradient(180deg, var(--bg-color) 0%, var(--bg-secondary) 100%); + flex-shrink: 0; /* Evita que se encoja */ +} +.commit-info { + display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; padding: 14px; + background: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); + transition: var(--transition); +} +.commit-info:hover { border-color: rgba(59, 130, 246, 0.5); background: rgba(59, 130, 246, 0.05); } +.commit-info i { color: var(--accent-color); flex-shrink: 0; margin-top: 2px; width: 18px; } +.commit-details { display: flex; flex-direction: column; gap: 5px; overflow: hidden; } +.commit-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; } +.commit-date { font-size: 14px; font-weight: 600; white-space: nowrap; color: var(--text-primary); } +.user-profile { + display: flex; align-items: center; gap: 14px; padding: 14px; background: var(--bg-card); + border-radius: 12px; border: 1px solid var(--border-color); transition: var(--transition); + cursor: pointer; position: relative; overflow: hidden; +} +.user-profile::before { /* Hover glow */ + content: ''; position: absolute; inset: 0; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), transparent); + opacity: 0; transition: opacity 0.3s ease; +} +.user-profile:hover { border-color: var(--accent-color); background: rgba(59, 130, 246, 0.08); transform: translateY(-2px); } +.user-profile:hover::before { opacity: 1; } +.user-avatar { + width: 46px; height: 46px; border-radius: 50%; border: 2px solid var(--accent-color); + object-fit: cover; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + position: relative; z-index: 1; flex-shrink: 0; +} +.user-details { display: flex; flex-direction: column; white-space: nowrap; gap: 3px; position: relative; z-index: 1; overflow: hidden;} +.user-name { font-weight: 700; font-size: 15px; color: var(--text-primary); } +.logout-link { font-size: 13px; color: var(--accent-light); text-decoration: none; transition: var(--transition); font-weight: 500; } +.logout-link:hover { color: var(--text-primary); } + + +/* ================================= + Estilo Colapsado Enhanced +================================= */ +.sidebar.collapsed { + width: var(--sidebar-width-collapsed); + /* No usar transform: translateX aquí para escritorio */ +} + +/* Ocultar elementos de texto */ +.sidebar.collapsed .logo-text, +.sidebar.collapsed .link-text, +.sidebar.collapsed .submenu-toggle, +.sidebar.collapsed .commit-details, /* Ocultar detalles del commit */ +.sidebar.collapsed .user-details, /* Ocultar detalles del usuario */ +.sidebar.collapsed .logo-icon { /* Ocultar icono de Java */ + opacity: 0; + width: 0; + height: 0; /* Asegurar colapso vertical */ + overflow: hidden; + pointer-events: none; + transition: opacity 0.1s ease, width 0.1s ease, height 0.1s ease; + display: none; /* Forzar ocultación completa */ +} + +/* Botón toggle siempre visible y accesible cuando colapsado */ +.sidebar.collapsed .sidebar-toggle { + transform: rotate(180deg); + /* Asegurar que permanezca visible y centrado */ + opacity: 1; + pointer-events: auto; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); /* Añadir brillo para destacar */ +} +.sidebar.collapsed .logo { justify-content: center; padding-right: 0; } +.sidebar.collapsed .sidebar-header { justify-content: center; } /* Centrar el contenido del header */ +.sidebar.collapsed .menu-item a { justify-content: center; padding: 16px; margin: 5px 8px; gap: 0; } +.sidebar.collapsed .menu-item a i { margin: 0; width: 20px; height: 20px; /* Tamaño fijo iconos colapsado */ } + +/* Estilo activo colapsado */ +.sidebar.collapsed .menu-item.active a { + border-left: none; /* Quitar borde izquierdo */ + background: linear-gradient(135deg, rgba(59, 130, 246, 0.3) 0%, rgba(251, 191, 36, 0.2) 100%); +} +.sidebar.collapsed .menu-item.active a::before { /* Usar borde izquierdo como indicador */ + content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: var(--accent-color); border-radius: 0 4px 4px 0; +} +.sidebar.collapsed .menu-item.active a::after { display: none; } /* Ocultar punto */ +.sidebar.collapsed .menu-item a:hover { transform: scale(1.05); } +.sidebar.collapsed .submenu { display: none; } + +/* Footer Colapsado */ +.sidebar.collapsed .sidebar-footer { padding: 15px; } +.sidebar.collapsed .commit-info, +.sidebar.collapsed .user-profile { + justify-content: center; padding: 10px; gap: 0; +} +.sidebar.collapsed .commit-info i { margin: 0; width: 20px; height: 20px; } +.sidebar.collapsed .user-avatar { + width: 40px; height: 40px; margin: 0; border-width: 1px; +} + + +/* ================================= + Contenido Principal Enhanced +================================= */ +.main-content { + flex-grow: 1; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + position: relative; /* Asegurar que ::before se posicione correctamente */ + transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* Transición para móvil */ +} + +/* ... (pseudoelemento ::before sin cambios) ... */ +.main-content::before { + content: ''; position: fixed; top: 0; right: 0; width: 50%; height: 50%; + background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.05) 0%, transparent 70%); + pointer-events: none; z-index: 0; /* Detrás del contenido */ +} + +.main-header { + height: 85px; border-bottom: 1px solid var(--border-color); padding: 0 45px; + display: flex; align-items: center; justify-content: space-between; + background: rgba(0,0,0,0.8); /* Fondo semi-transparente */ + backdrop-filter: blur(10px); /* Desenfoque */ + position: sticky; top: 0; z-index: 50; flex-shrink: 0; +} +.main-header h1 { + font-size: 30px; font-weight: 800; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--accent-light) 100%); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + letter-spacing: -1px; +} +.header-actions { display: flex; gap: 14px; } +.header-actions .action-btn { /* Estilos botones header */ + background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-secondary); + cursor: pointer; padding: 12px; border-radius: 12px; transition: var(--transition); + display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; +} +.header-actions .action-btn::before { /* Efecto hover */ + content: ''; position: absolute; top: 50%; left: 50%; width: 0; height: 0; + background: rgba(59, 130, 246, 0.2); border-radius: 50%; + transform: translate(-50%, -50%); transition: width 0.6s ease, height 0.6s ease; +} +.header-actions .action-btn:hover::before { width: 200px; height: 200px; } +.header-actions .action-btn:hover { + color: var(--text-primary); border-color: var(--accent-color); + background: rgba(59, 130, 246, 0.1); transform: translateY(-2px); +} +.header-actions .action-btn i { position: relative; z-index: 1; width: 18px; height: 18px; } + +.content-wrapper { + padding: 45px; flex-grow: 1; overflow-y: auto; position: relative; z-index: 1; +} +.widget-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 28px; margin-bottom: 35px; +} + +/* Widget Enhanced */ +.widget { + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 18px; + padding: 32px; transition: var(--transition); position: relative; overflow: hidden; +} +.widget::before { /* Barra superior hover */ + content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; + background: linear-gradient(90deg, var(--accent-color) 0%, var(--accent-light) 100%); + opacity: 0; transition: var(--transition); +} +.widget::after { /* Brillo hover */ + content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; + background: radial-gradient(circle, rgba(59, 130, 246, 0.05) 0%, transparent 70%); + opacity: 0; transition: opacity 0.3s ease; pointer-events: none; +} +.widget:hover { border-color: var(--accent-color); transform: translateY(-6px); box-shadow: 0 15px 40px rgba(59, 130, 246, 0.25); } +.widget:hover::before { opacity: 1; } +.widget:hover::after { opacity: 1; } + +.widget-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } +.widget h3 { font-size: 13px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1px; } +.widget-icon { + width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; + background: rgba(59, 130, 246, 0.15); color: var(--accent-color); transition: var(--transition); flex-shrink: 0; +} +.widget:hover .widget-icon { transform: scale(1.1) rotate(5deg); } +.widget .stat { font-size: 46px; font-weight: 800; color: var(--text-primary); margin-bottom: 10px; line-height: 1; letter-spacing: -2px; } +.widget .stat-label { font-size: 14px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; font-weight: 500; } +.stat-change { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 8px; font-size: 13px; font-weight: 700; } +.stat-change.positive { background: rgba(34, 197, 94, 0.15); color: var(--success-color); } +.stat-change.negative { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); } + +/* Progress Widget Enhanced */ +.progress-widget { grid-column: span 2; } +.progress-bar { width: 100%; height: 14px; background: var(--bg-secondary); border-radius: 12px; overflow: hidden; margin-top: 20px; box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); } +.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-color) 0%, var(--accent-light) 100%); border-radius: 12px; transition: width 1.5s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } +.progress-fill::after { /* Shimmer effect */ + content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); animation: shimmer 2s infinite; +} + +/* Activity Widget Enhanced */ +.activity-list { display: flex; flex-direction: column; gap: 14px; } +.activity-item { display: flex; align-items: center; gap: 14px; padding: 14px; background: var(--bg-secondary); border-radius: 12px; transition: var(--transition); border: 1px solid transparent; } +.activity-item:hover { background: rgba(59, 130, 246, 0.05); border-color: rgba(59, 130, 246, 0.3); transform: translateX(4px); } +.activity-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(59, 130, 246, 0.15); color: var(--accent-color); flex-shrink: 0; transition: var(--transition); } +.activity-item:hover .activity-icon { transform: scale(1.1); } +.activity-details { flex-grow: 1; } +.activity-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 3px; } +.activity-time { font-size: 13px; color: var(--text-tertiary); } + +/* Recent Submissions */ +.recent-submissions-list { display: flex; flex-direction: column; gap: 12px; max-height: 350px; overflow-y: auto; } +.recent-submissions-list .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-tertiary); } +.recent-submissions-list .empty-state i { width: 48px; height: 48px; margin-bottom: 12px; opacity: 0.5; } +.recent-submissions-list .empty-state p { font-size: 14px; margin: 0; } + +.submission-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--bg-secondary); border-radius: 10px; border: 1px solid transparent; transition: var(--transition); } +.submission-item:hover { background: rgba(59, 130, 246, 0.05); border-color: rgba(59, 130, 246, 0.2); transform: translateX(3px); } + +.submission-status { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: var(--transition); } +.submission-status.success { background: rgba(34, 197, 94, 0.15); color: var(--success-color); } +.submission-status.failed { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); } +.submission-status.partial { background: rgba(251, 191, 36, 0.15); color: var(--accent-yellow); } +.submission-item:hover .submission-status { transform: scale(1.1); } + +.submission-info { flex-grow: 1; min-width: 0; } +.submission-exercise-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.submission-time { font-size: 12px; color: var(--text-tertiary); } + +.submission-stats { display: flex; gap: 12px; align-items: center; flex-shrink: 0; } +.submission-stat { display: flex; flex-direction: column; align-items: center; gap: 2px; } +.submission-stat-value { font-size: 16px; font-weight: 700; } +.submission-stat-value.success { color: var(--success-color); } +.submission-stat-value.failed { color: var(--danger-color); } +.submission-stat-label { font-size: 10px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; } + +/* Widget Color Variations Enhanced */ +.widget:nth-child(1) .widget-icon { background: rgba(34, 197, 94, 0.15); color: var(--success-color); } +.widget:nth-child(2) .widget-icon { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); } +.widget:nth-child(3) .widget-icon { background: rgba(59, 130, 246, 0.15); color: var(--accent-color); } +.widget:nth-child(4) .widget-icon { background: rgba(251, 191, 36, 0.15); color: var(--accent-yellow); } + +/* Special styling for failed tests counter */ +#testsFailed { color: var(--danger-color); } +#testsPassed { color: var(--success-color); } + +/* Highlight tests failed widget when there are failures */ +.widget.has-failures { border-color: rgba(239, 68, 68, 0.5) !important; animation: pulse-red 2s infinite; } +@keyframes pulse-red { + 0%, 100% { box-shadow: 0 4px 20px rgba(239, 68, 68, 0.2); } + 50% { box-shadow: 0 4px 30px rgba(239, 68, 68, 0.4); } +} + + +/* ================================ + SKELETON LOADERS +================================ */ +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-secondary) 0%, + var(--border-color) 50%, + var(--bg-secondary) 100% + ); + background-size: 200% 100%; + animation: skeletonLoading 1.5s ease-in-out infinite; + border-radius: 8px; +} + +.skeleton-text { + height: 20px; + margin-bottom: 10px; + border-radius: 4px; +} + +.skeleton-text.large { + height: 46px; + width: 60%; +} + +.skeleton-text.small { + height: 14px; + width: 40%; +} + +.widget.loading .stat, +.widget.loading .stat-label, +.widget.loading .widget-icon i { + opacity: 0; +} + +.widget.loading .widget-header::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 44px; + height: 44px; + background: linear-gradient( + 90deg, + var(--bg-secondary) 0%, + var(--border-color) 50%, + var(--bg-secondary) 100% + ); + background-size: 200% 100%; + animation: skeletonLoading 1.5s ease-in-out infinite; + border-radius: 12px; +} + + +/* ================================ + TOOLTIPS +================================ */ +.tooltip { + position: relative; + cursor: help; +} + +.tooltip::before, +.tooltip::after { + position: absolute; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; + z-index: 1000; +} + +.tooltip::before { + content: attr(data-tooltip); + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(-5px); + padding: 8px 12px; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 13px; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.tooltip::after { + content: ''; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%) translateY(-5px); + border: 6px solid transparent; + border-top-color: var(--border-color); +} + +.tooltip:hover::before, +.tooltip:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + + +/* ================================ + EMPTY STATES +================================ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + opacity: 0; + animation: fadeInUp 0.6s ease-out 0.2s forwards; +} + +.empty-state-icon { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + animation: bounce 2s ease-in-out infinite; +} + +.empty-state-icon i { + width: 40px; + height: 40px; + color: var(--accent-color); +} + +.empty-state h3 { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 10px; +} + +.empty-state p { + font-size: 14px; + color: var(--text-secondary); + max-width: 300px; + line-height: 1.6; +} + +.empty-state-cta { + margin-top: 20px; + padding: 12px 24px; + background: var(--accent-color); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.empty-state-cta:hover { + background: var(--accent-light); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); +} + + +/* ================================ + NOTIFICATION BADGE +================================ */ +.notification-badge { + position: absolute; + top: -4px; + right: -4px; + width: 18px; + height: 18px; + background: var(--danger-color); + color: white; + border-radius: 50%; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--bg-card); + animation: pulse 2s ease-in-out infinite; +} + + +/* ================================ + LOADING SPINNER +================================ */ +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 18px; +} + + +/* ================================ + SUCCESS/ERROR TOAST +================================ */ +.toast { + position: fixed; + top: 100px; + right: 20px; + padding: 16px 20px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + gap: 12px; + min-width: 300px; + z-index: 10000; + animation: slideInFromRight 0.3s ease-out; +} + +.toast.success { + border-left: 4px solid var(--success-color); +} + +.toast.error { + border-left: 4px solid var(--danger-color); +} + +.toast.info { + border-left: 4px solid var(--info-color); +} + +.toast-icon { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.toast.success .toast-icon { color: var(--success-color); } +.toast.error .toast-icon { color: var(--danger-color); } +.toast.info .toast-icon { color: var(--info-color); } + +.toast-content { + flex-grow: 1; +} + +.toast-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.toast-message { + font-size: 13px; + color: var(--text-secondary); +} + +.toast-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.toast-close:hover { + color: var(--text-primary); + transform: scale(1.1); +} + + +/* ================================ + RESPONSIVE + CORRECCIONES +================================ */ + +/* --- Botón de Toggle para Móviles --- */ +.mobile-sidebar-toggle { + display: none; /* Oculto por defecto en desktop */ + position: fixed; + top: 20px; + left: 20px; + z-index: 1001; /* Encima del sidebar */ + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 12px; + border-radius: 12px; + cursor: pointer; + transition: var(--transition); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* Sombra para destacar */ + width: 48px; /* Tamaño fijo */ + height: 48px; /* Tamaño fijo */ + align-items: center; + justify-content: center; +} +.mobile-sidebar-toggle:hover { + color: var(--text-primary); + border-color: var(--accent-color); + background: rgba(59, 130, 246, 0.1); + transform: scale(1.05); +} +.mobile-sidebar-toggle i { + width: 24px; + height: 24px; +} + +/* --- Overlay para Móviles --- */ +.sidebar-overlay { + display: none; /* Oculto por defecto */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 999; /* Debajo del sidebar pero encima del contenido */ + opacity: 0; + transition: opacity 0.3s ease; + backdrop-filter: blur(4px); +} + +.sidebar-overlay.active { + display: block; + opacity: 1; +} + +/* --- Ajustes Tablet/Móvil (<= 1024px) --- */ +@media (max-width: 1024px) { + .sidebar { + position: fixed; /* Posición fija */ + left: 0; + top: 0; + height: 100vh; + z-index: 1000; + transform: translateX(0); /* Empieza visible por defecto */ + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); + /* Aplicar transición al transform */ + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .sidebar.collapsed { + /* Se esconde completamente fuera de la pantalla */ + transform: translateX(-100%); + /* Ancho no necesario aquí, ya se esconde */ + } + + /* Ocultar el toggle de desktop en pantallas pequeñas */ + .sidebar-toggle { + display: none; + } + + /* Mostrar el nuevo botón hamburguesa - SIEMPRE visible */ + .mobile-sidebar-toggle { + display: flex; + align-items: center; + justify-content: center; + } + + /* Cuando el sidebar está abierto, el botón sigue visible */ + .sidebar:not(.collapsed) ~ .main-content .mobile-sidebar-toggle { + left: calc(var(--sidebar-width) + 20px); /* Mover a la derecha del sidebar */ + transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + /* Ajustar contenido principal */ + .main-content { + margin-left: 0; /* Quitar margen izquierdo */ + width: 100%; + /* Añadir padding top para dejar espacio al header y botón */ + padding-top: 90px; + } + + .main-header { + /* Mover el header un poco a la derecha para no solaparse con el botón */ + padding-left: 70px; + } + + .progress-widget { + grid-column: span 1; /* Widgets de progreso ocupan 1 columna */ + } + + .widget-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } +} + + +/* --- Ajustes Móvil Pequeño (<= 768px) --- */ +@media (max-width: 768px) { + .content-wrapper { padding: 24px; } + .widget-grid { grid-template-columns: 1fr; gap: 20px; } + .main-header { padding: 0 24px; height: 75px; } + .main-header h1 { font-size: 22px; } + /* No necesitamos redefinir .sidebar aquí */ + .widget { padding: 24px; } + .widget .stat { font-size: 38px; } + .header-actions { gap: 10px; } + .header-actions .action-btn { padding: 10px; } +} + + +/* --- Ajustes Móvil Muy Pequeño (<= 480px) --- */ +@media (max-width: 480px) { + .content-wrapper { padding: 20px; } + .main-header { padding: 0 20px; height: 70px; padding-left: 60px; /* Mantener espacio botón */ } + .main-header h1 { font-size: 20px; } + .header-actions .action-btn { padding: 8px; } + .header-actions .action-btn i { width: 16px; height: 16px; } + .widget-grid { gap: 16px; } + .widget { padding: 20px; } + .widget .stat { font-size: 34px; } + .widget h3 { font-size: 12px; } + .widget-icon { width: 40px; height: 40px; } + + /* Ajustar posición botón hamburguesa */ + .mobile-sidebar-toggle { + top: 15px; + left: 15px; + } +} + + +/* ============================================ + HELP MODAL + ============================================ */ + +.help-modal { + display: none; /* Hidden by default */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.3s ease-out; +} + +.help-modal.active { + display: flex; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.help-modal-content { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + max-width: 500px; + width: 100%; + position: relative; + animation: slideUp 0.3s ease-out; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +@keyframes slideUp { + from { + transform: translateY(30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.help-modal-close { + position: absolute; + top: 16px; + right: 16px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +.help-modal-close:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color); +} + +.help-modal-close i { + width: 20px; + height: 20px; +} + +.help-modal-header { + text-align: center; + padding: 40px 32px 24px; + border-bottom: 1px solid var(--border-color); +} + +.help-profile-img { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; + margin-bottom: 16px; + border: 3px solid var(--accent-color); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.help-modal-header h2 { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.help-subtitle { + font-size: 14px; + color: var(--text-secondary); +} + +.help-modal-body { + padding: 24px 32px; +} + +.help-section { + margin-bottom: 24px; +} + +.help-section:last-child { + margin-bottom: 0; +} + +.help-section h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.help-section h3 i { + width: 16px; + height: 16px; +} + +.help-info-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 16px; +} + +.help-email { + font-size: 14px; + color: var(--text-primary); + word-break: break-all; +} + +.copy-email-btn { + background: transparent; + border: none; + color: var(--accent-color); + cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.copy-email-btn:hover { + background: rgba(59, 130, 246, 0.1); + color: var(--accent-light); +} + +.copy-email-btn.copied { + color: var(--success-color); +} + +.copy-email-btn i { + width: 18px; + height: 18px; +} + +.help-whatsapp-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 12px 24px; + background: linear-gradient(135deg, #25D366 0%, #128C7E 100%); + color: white; + text-decoration: none; + border-radius: 8px; + font-weight: 500; + font-size: 14px; + transition: var(--transition); +} + +.help-whatsapp-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(37, 211, 102, 0.4); +} + +.help-whatsapp-btn i { + width: 18px; + height: 18px; +} + +.help-github-link { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); + text-decoration: none; + border-radius: 8px; + font-size: 14px; + transition: var(--transition); +} + +.help-github-link:hover { + border-color: var(--accent-color); + background: rgba(59, 130, 246, 0.05); +} + +.help-github-link i { + width: 16px; + height: 16px; + color: var(--text-secondary); +} + +.help-modal-footer { + padding: 16px 32px 32px; + text-align: center; +} + +.help-modal-footer p { + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.help-modal-footer i { + width: 16px; + height: 16px; + color: var(--danger-color); +} + +/* Mobile Responsive */ +@media (max-width: 480px) { + .help-modal-content { + max-width: 100%; + border-radius: 12px; + } + + .help-modal-header { + padding: 32px 24px 20px; + } + + .help-profile-img { + width: 80px; + height: 80px; + } + + .help-modal-header h2 { + font-size: 20px; + } + + .help-modal-body { + padding: 20px 24px; + } + + .help-modal-footer { + padding: 16px 24px 28px; + } +} \ No newline at end of file diff --git a/src/css/exercises.css b/src/css/exercises.css new file mode 100644 index 0000000..59855ae --- /dev/null +++ b/src/css/exercises.css @@ -0,0 +1,3714 @@ +/* ================================ + EXERCISES PAGE STYLES +================================ */ +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-color: #000; + --bg-secondary: #0d0d0d; + --bg-card: #1a1a1a; + --border-color: #2a2a2a; + --text-primary: #fff; + --text-secondary: #a0a0a0; + --text-tertiary: #666; + --accent-color: #3b82f6; /* Azul principal */ + --accent-light: #60a5fa; /* Azul claro */ + --accent-dark: #1e40af; /* Azul oscuro */ + --accent-yellow: #fbbf24; /* Amarillo */ + --accent-yellow-light: #fcd34d; /* Amarillo claro */ + --success-color: #22c55e; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --info-color: #3b82f6; + --sidebar-width: 280px; + --sidebar-width-collapsed: 80px; + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-color); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.dashboard-layout { + display: flex; + min-height: 100vh; + position: relative; +} + +/* Enhanced Scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg-color); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 5px; + transition: var(--transition); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-color); +} + +/* Enhanced Animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes skeletonLoading { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +/* Apply animation stagger */ +.widget { + animation: fadeInUp 0.7s ease-out backwards; +} +.widget:nth-child(1) { animation-delay: 0.1s; } +.widget:nth-child(2) { animation-delay: 0.15s; } +.widget:nth-child(3) { animation-delay: 0.2s; } +.widget:nth-child(4) { animation-delay: 0.25s; } +.widget:nth-child(5) { animation-delay: 0.3s; } +.widget:nth-child(6) { animation-delay: 0.35s; } + + +/* ================================= + Sidebar Enhanced (Menú Lateral) +================================= */ +/* ================================= + Sidebar Enhanced (Menú Lateral) - SIMPLIFIED +================================= */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-color); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + position: relative; + z-index: 100; + flex-shrink: 0; +} + +.sidebar::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 1px; + height: 100%; + background: linear-gradient(180deg, transparent 0%, var(--accent-color) 30%, var(--accent-yellow) 50%, var(--accent-color) 70%, transparent 100%); + opacity: 0.4; +} + +.sidebar::after { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(59, 130, 246, 0.03) 0%, transparent 70%); + pointer-events: none; +} + +/* --- Sidebar Header con Logo y Toggle --- */ +.sidebar-header { + padding: 28px 22px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.logo { + display: flex; align-items: center; gap: 14px; text-decoration: none; + color: var(--text-primary); transition: var(--transition); +} +.logo:hover { transform: translateX(3px); } +.logo-icon { + width: 38px; height: 38px; border-radius: 10px; + transition: var(--transition); +} +.logo:hover .logo-icon { + transform: scale(1.05); +} +.logo-text { + font-size: 21px; font-weight: 700; white-space: nowrap; opacity: 1; + transition: opacity 0.3s ease; letter-spacing: -0.5px; + background: linear-gradient(135deg, #ffffff 0%, #d1d5db 100%); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; +} + +.sidebar-toggle { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-secondary); + cursor: pointer; + padding: 10px; + border-radius: 10px; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 10; +} + +.sidebar-toggle:hover { + color: var(--text-primary); + background: rgba(59, 130, 246, 0.1); + border-color: var(--accent-color); + transform: scale(1.05); +} + +.sidebar-toggle i { + width: 20px; + height: 20px; +} + +/* --- Menú Links Enhanced with Professional Active State --- */ +.menu-links { + list-style: none; + padding: 24px 0; + flex-grow: 1; + overflow-y: auto; +} + +.menu-item a { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 22px; + margin: 5px 14px; + color: #9ca3af; + text-decoration: none; + white-space: nowrap; + transition: var(--transition); + border-radius: 12px; + position: relative; + font-weight: 500; + font-size: 15px; +} + +.menu-item a::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0; + background: linear-gradient(90deg, var(--accent-color), transparent); + border-radius: 12px 0 0 12px; + transition: width 0.3s ease; +} + +.menu-item a:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.05); + transform: translateX(5px); +} + +.menu-item a:hover::before { + width: 4px; +} + +/* Professional Active State - Clear Background Indicator */ +.menu-item.active a { + color: #ffffff; + font-weight: 700; + background: rgba(59, 130, 246, 0.15); + border-left: 3px solid var(--accent-color); +} + +.menu-item a .link-text { + opacity: 1; + transition: opacity 0.3s ease; +} + +.menu-item a i { + flex-shrink: 0; + width: 18px; +} + +/* Submenú Enhanced */ +.submenu-toggle { margin-left: auto; transition: transform 0.3s ease; } +.submenu { + list-style: none; padding-left: 52px; max-height: 0; overflow: hidden; + transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} +.menu-item.submenu-open .submenu { max-height: 600px; } +.menu-item.submenu-open .submenu-toggle { transform: rotate(90deg); color: var(--accent-color); } +.submenu-item a { padding: 12px 22px; font-size: 14px; margin: 3px 14px; } + +/* --- Footer del Sidebar Enhanced --- */ +.sidebar-footer { + padding: 22px; border-top: 1px solid var(--border-color); + background: linear-gradient(180deg, var(--bg-color) 0%, var(--bg-secondary) 100%); + flex-shrink: 0; /* Evita que se encoja */ +} +.commit-info { + display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; padding: 14px; + background: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); + transition: var(--transition); +} +.commit-info:hover { border-color: rgba(59, 130, 246, 0.5); background: rgba(59, 130, 246, 0.05); } +.commit-info i { color: var(--accent-color); flex-shrink: 0; margin-top: 2px; width: 18px; } +.commit-details { display: flex; flex-direction: column; gap: 5px; overflow: hidden; } +.commit-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; } +.commit-date { font-size: 14px; font-weight: 600; white-space: nowrap; color: var(--text-primary); } +.user-profile { + display: flex; align-items: center; gap: 14px; padding: 14px; background: var(--bg-card); + border-radius: 12px; border: 1px solid var(--border-color); transition: var(--transition); + cursor: pointer; position: relative; overflow: hidden; +} +.user-profile::before { /* Hover glow */ + content: ''; position: absolute; inset: 0; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), transparent); + opacity: 0; transition: opacity 0.3s ease; +} +.user-profile:hover { border-color: var(--accent-color); background: rgba(59, 130, 246, 0.08); transform: translateY(-2px); } +.user-profile:hover::before { opacity: 1; } +.user-avatar { + width: 46px; height: 46px; border-radius: 50%; border: 2px solid var(--accent-color); + object-fit: cover; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + position: relative; z-index: 1; flex-shrink: 0; +} +.user-details { display: flex; flex-direction: column; white-space: nowrap; gap: 3px; position: relative; z-index: 1; overflow: hidden;} +.user-name { font-weight: 700; font-size: 15px; color: var(--text-primary); } +.logout-link { font-size: 13px; color: var(--accent-light); text-decoration: none; transition: var(--transition); font-weight: 500; } +.logout-link:hover { color: var(--text-primary); } + + +/* ================================= + Estilo Colapsado Enhanced +================================= */ +.sidebar.collapsed { + width: var(--sidebar-width-collapsed); + /* No usar transform: translateX aquí para escritorio */ +} + +/* Ocultar elementos de texto */ +.sidebar.collapsed .logo-text, +.sidebar.collapsed .link-text, +.sidebar.collapsed .submenu-toggle, +.sidebar.collapsed .commit-details, /* Ocultar detalles del commit */ +.sidebar.collapsed .user-details, /* Ocultar detalles del usuario */ +.sidebar.collapsed .logo-icon { /* Ocultar icono de Java */ + opacity: 0; + width: 0; + height: 0; /* Asegurar colapso vertical */ + overflow: hidden; + pointer-events: none; + transition: opacity 0.1s ease, width 0.1s ease, height 0.1s ease; + display: none; /* Forzar ocultación completa */ +} + +/* Botón toggle siempre visible y accesible cuando colapsado */ +.sidebar.collapsed .sidebar-toggle { + transform: rotate(180deg); + /* Asegurar que permanezca visible y centrado */ + opacity: 1; + pointer-events: auto; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); /* Añadir brillo para destacar */ +} +.sidebar.collapsed .logo { justify-content: center; padding-right: 0; } +.sidebar.collapsed .sidebar-header { justify-content: center; } /* Centrar el contenido del header */ +.sidebar.collapsed .menu-item a { justify-content: center; padding: 16px; margin: 5px 8px; gap: 0; } +.sidebar.collapsed .menu-item a i { margin: 0; width: 20px; height: 20px; /* Tamaño fijo iconos colapsado */ } + +/* Estilo activo colapsado */ +.sidebar.collapsed .menu-item.active a { + border-left: none; /* Quitar borde izquierdo */ + background: linear-gradient(135deg, rgba(59, 130, 246, 0.3) 0%, rgba(251, 191, 36, 0.2) 100%); +} +.sidebar.collapsed .menu-item.active a::before { /* Usar borde izquierdo como indicador */ + content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: var(--accent-color); border-radius: 0 4px 4px 0; +} +.sidebar.collapsed .menu-item.active a::after { display: none; } /* Ocultar punto */ +.sidebar.collapsed .menu-item a:hover { transform: scale(1.05); } +.sidebar.collapsed .submenu { display: none; } + +/* Footer Colapsado */ +.sidebar.collapsed .sidebar-footer { padding: 15px; } +.sidebar.collapsed .commit-info, +.sidebar.collapsed .user-profile { + justify-content: center; padding: 10px; gap: 0; +} +.sidebar.collapsed .commit-info i { margin: 0; width: 20px; height: 20px; } +.sidebar.collapsed .user-avatar { + width: 40px; height: 40px; margin: 0; border-width: 1px; +} + + +/* ================================= + Contenido Principal Enhanced +================================= */ +.main-content { + flex-grow: 1; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + position: relative; + transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.main-content::before { + content: ''; + position: fixed; + top: 0; + right: 0; + width: 50%; + height: 50%; + background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.05) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +.content-wrapper { + padding: 45px; + flex-grow: 1; + overflow-y: auto; + position: relative; + z-index: 1; +} +.widget-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 28px; margin-bottom: 35px; +} + +/* Widget Enhanced */ +.widget { + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 18px; + padding: 32px; transition: var(--transition); position: relative; overflow: hidden; +} +.widget::before { /* Barra superior hover */ + content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; + background: linear-gradient(90deg, var(--accent-color) 0%, var(--accent-light) 100%); + opacity: 0; transition: var(--transition); +} +.widget::after { /* Brillo hover */ + content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; + background: radial-gradient(circle, rgba(59, 130, 246, 0.05) 0%, transparent 70%); + opacity: 0; transition: opacity 0.3s ease; pointer-events: none; +} +.widget:hover { border-color: var(--accent-color); transform: translateY(-6px); box-shadow: 0 15px 40px rgba(59, 130, 246, 0.25); } +.widget:hover::before { opacity: 1; } +.widget:hover::after { opacity: 1; } + +.widget-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } +.widget h3 { font-size: 13px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1px; } +.widget-icon { + width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; + background: rgba(59, 130, 246, 0.15); color: var(--accent-color); transition: var(--transition); flex-shrink: 0; +} +.widget:hover .widget-icon { transform: scale(1.1) rotate(5deg); } +.widget .stat { font-size: 46px; font-weight: 800; color: var(--text-primary); margin-bottom: 10px; line-height: 1; letter-spacing: -2px; } +.widget .stat-label { font-size: 14px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; font-weight: 500; } +.stat-change { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 8px; font-size: 13px; font-weight: 700; } +.stat-change.positive { background: rgba(34, 197, 94, 0.15); color: var(--success-color); } +.stat-change.negative { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); } + +/* Progress Widget Enhanced */ +.progress-widget { grid-column: span 2; } +.progress-bar { width: 100%; height: 14px; background: var(--bg-secondary); border-radius: 12px; overflow: hidden; margin-top: 20px; box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); } +.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-color) 0%, var(--accent-light) 100%); border-radius: 12px; transition: width 1.5s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } +.progress-fill::after { /* Shimmer effect */ + content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); animation: shimmer 2s infinite; +} + +/* Activity Widget Enhanced */ +.activity-list { display: flex; flex-direction: column; gap: 14px; } +.activity-item { display: flex; align-items: center; gap: 14px; padding: 14px; background: var(--bg-secondary); border-radius: 12px; transition: var(--transition); border: 1px solid transparent; } +.activity-item:hover { background: rgba(59, 130, 246, 0.05); border-color: rgba(59, 130, 246, 0.3); transform: translateX(4px); } +.activity-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(59, 130, 246, 0.15); color: var(--accent-color); flex-shrink: 0; transition: var(--transition); } +.activity-item:hover .activity-icon { transform: scale(1.1); } +.activity-details { flex-grow: 1; } +.activity-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 3px; } +.activity-time { font-size: 13px; color: var(--text-tertiary); } + +/* Widget Color Variations Enhanced */ +.widget:nth-child(1) .widget-icon { background: rgba(34, 197, 94, 0.15); color: var(--success-color); } +.widget:nth-child(2) .widget-icon { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); } +.widget:nth-child(3) .widget-icon { background: rgba(59, 130, 246, 0.15); color: var(--accent-color); } +.widget:nth-child(4) .widget-icon { background: rgba(251, 191, 36, 0.15); color: var(--accent-yellow); } + + +/* ================================ + SKELETON LOADERS +================================ */ +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-secondary) 0%, + var(--border-color) 50%, + var(--bg-secondary) 100% + ); + background-size: 200% 100%; + animation: skeletonLoading 1.5s ease-in-out infinite; + border-radius: 8px; +} + +.skeleton-text { + height: 20px; + margin-bottom: 10px; + border-radius: 4px; +} + +.skeleton-text.large { + height: 46px; + width: 60%; +} + +.skeleton-text.small { + height: 14px; + width: 40%; +} + +.widget.loading .stat, +.widget.loading .stat-label, +.widget.loading .widget-icon i { + opacity: 0; +} + +.widget.loading .widget-header::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 44px; + height: 44px; + background: linear-gradient( + 90deg, + var(--bg-secondary) 0%, + var(--border-color) 50%, + var(--bg-secondary) 100% + ); + background-size: 200% 100%; + animation: skeletonLoading 1.5s ease-in-out infinite; + border-radius: 12px; +} + + +/* ================================ + TOOLTIPS +================================ */ +.tooltip { + position: relative; + cursor: help; +} + +.tooltip::before, +.tooltip::after { + position: absolute; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; + z-index: 1000; +} + +.tooltip::before { + content: attr(data-tooltip); + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(-5px); + padding: 8px 12px; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 13px; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.tooltip::after { + content: ''; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%) translateY(-5px); + border: 6px solid transparent; + border-top-color: var(--border-color); +} + +.tooltip:hover::before, +.tooltip:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + + +/* ================================ + EMPTY STATES +================================ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + opacity: 0; + animation: fadeInUp 0.6s ease-out 0.2s forwards; +} + +.empty-state-icon { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + animation: bounce 2s ease-in-out infinite; +} + +.empty-state-icon i { + width: 40px; + height: 40px; + color: var(--accent-color); +} + +.empty-state h3 { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 10px; +} + +.empty-state p { + font-size: 14px; + color: var(--text-secondary); + max-width: 300px; + line-height: 1.6; +} + +.empty-state-cta { + margin-top: 20px; + padding: 12px 24px; + background: var(--accent-color); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.empty-state-cta:hover { + background: var(--accent-light); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); +} + + +/* ================================ + NOTIFICATION BADGE +================================ */ +.notification-badge { + position: absolute; + top: -4px; + right: -4px; + width: 18px; + height: 18px; + background: var(--danger-color); + color: white; + border-radius: 50%; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--bg-card); + animation: pulse 2s ease-in-out infinite; +} + + +/* ================================ + LOADING SPINNER +================================ */ +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 18px; +} + + +/* ================================ + SUCCESS/ERROR TOAST - IMPROVED +================================ */ +.toast { + position: fixed; + top: 90px; + right: 20px; + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + gap: 10px; + max-width: 320px; + min-width: 280px; + z-index: 10000; + animation: slideInFromRight 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + backdrop-filter: blur(10px); + opacity: 0.95; +} + +.toast.success { + border-left: 3px solid var(--success-color); + background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, var(--bg-card) 50%); +} + +.toast.error { + border-left: 3px solid var(--danger-color); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, var(--bg-card) 50%); +} + +.toast.info { + border-left: 3px solid var(--info-color); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, var(--bg-card) 50%); +} + +.toast-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.toast.success .toast-icon { color: var(--success-color); } +.toast.error .toast-icon { color: var(--danger-color); } +.toast.info .toast-icon { color: var(--info-color); } + +.toast-content { + flex-grow: 1; + min-width: 0; +} + +.toast-title { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); + margin-bottom: 2px; + line-height: 1.3; +} + +.toast-message { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toast-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + flex-shrink: 0; + border-radius: 4px; +} + +.toast-close:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + +.toast-close i { + width: 16px; + height: 16px; +} + + +/* ================================ + RESPONSIVE + CORRECCIONES +================================ */ + +/* --- Botón de Toggle para Móviles --- */ +/* Mobile Sidebar Toggle Button */ +.mobile-sidebar-toggle { + display: none; /* Oculto por defecto en desktop */ + position: fixed; + top: 12px; + left: 12px; + z-index: 1001; /* Encima del sidebar y header */ + background: rgba(26, 26, 26, 0.95); + backdrop-filter: blur(10px); + border: 1px solid #444444; + color: #ffffff; + padding: 0; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + width: 44px; + height: 44px; + align-items: center; + justify-content: center; +} + +.mobile-sidebar-toggle:hover { + background: rgba(59, 130, 246, 0.2); + border-color: var(--accent-color); + transform: scale(1.05); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.3); +} + +.mobile-sidebar-toggle:active { + transform: scale(0.95); +} + +.mobile-sidebar-toggle i { + width: 22px; + height: 22px; + color: #ffffff; +} + +/* --- Overlay para Móviles --- */ +.sidebar-overlay { + display: none; /* Oculto por defecto */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 999; /* Debajo del sidebar pero encima del contenido */ + opacity: 0; + transition: opacity 0.3s ease; + backdrop-filter: blur(4px); +} + +.sidebar-overlay.active { + display: block; + opacity: 1; +} + +/* --- Ajustes Tablet/Móvil (<= 1024px) --- */ +@media (max-width: 1024px) { + /* Ocultar botón de toggle de desktop */ + .sidebar-toggle { + display: none; + } + + .sidebar { + position: fixed; + left: 0; + top: 0; + height: 100vh; + z-index: 1000; + transform: translateX(0); + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .sidebar.collapsed { + transform: translateX(-100%); + } + + /* Mostrar botón hamburguesa */ + .mobile-sidebar-toggle { + display: flex; + } + + /* Ajustar contenido principal */ + .main-content { + margin-left: 0; + width: 100%; + /* Añadir padding top para dejar espacio al header y botón */ + padding-top: 90px; + } + + .main-header { + /* Mover el header un poco a la derecha para no solaparse con el botón */ + padding-left: 70px; + } + + .progress-widget { + grid-column: span 1; /* Widgets de progreso ocupan 1 columna */ + } + + .widget-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } +} + + +/* --- Ajustes Móvil Pequeño (<= 768px) --- */ +@media (max-width: 768px) { + .content-wrapper { padding: 24px; } + .widget-grid { grid-template-columns: 1fr; gap: 20px; } + .main-header { padding: 0 24px; height: 75px; } + .main-header h1 { font-size: 22px; } + /* No necesitamos redefinir .sidebar aquí */ + .widget { padding: 24px; } + .widget .stat { font-size: 38px; } + .header-actions { gap: 10px; } + .header-actions .action-btn { padding: 10px; } + + /* Toast más pequeño en móvil */ + .toast { + top: auto; + bottom: 20px; + right: 10px; + left: 10px; + max-width: none; + min-width: auto; + padding: 10px 12px; + } + + .toast-title { + font-size: 12px; + } + + .toast-message { + font-size: 11px; + } + + .toast-icon { + width: 18px; + height: 18px; + } +} + + +/* --- Ajustes Móvil Muy Pequeño (<= 480px) --- */ +@media (max-width: 480px) { + .content-wrapper { padding: 20px; } + .main-header { padding: 0 20px; height: 70px; padding-left: 60px; /* Mantener espacio botón */ } + .main-header h1 { font-size: 20px; } + .header-actions .action-btn { padding: 8px; } + .header-actions .action-btn i { width: 16px; height: 16px; } + .widget-grid { gap: 16px; } + .widget { padding: 20px; } + .widget .stat { font-size: 34px; } + .widget h3 { font-size: 12px; } + .widget-icon { width: 40px; height: 40px; } + + /* Ajustar posición botón hamburguesa */ + .mobile-sidebar-toggle { + top: 15px; + left: 15px; + } +} + + +/* ============================================ + HELP MODAL + ============================================ */ + +.help-modal { + display: none; /* Hidden by default */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.3s ease-out; +} + +.help-modal.active { + display: flex; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.help-modal-content { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + max-width: 500px; + width: 100%; + position: relative; + animation: slideUp 0.3s ease-out; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +@keyframes slideUp { + from { + transform: translateY(30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.help-modal-close { + position: absolute; + top: 16px; + right: 16px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +.help-modal-close:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color); +} + +.help-modal-close i { + width: 20px; + height: 20px; +} + +.help-modal-header { + text-align: center; + padding: 40px 32px 24px; + border-bottom: 1px solid var(--border-color); +} + +.help-profile-img { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; + margin-bottom: 16px; + border: 3px solid var(--accent-color); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.help-modal-header h2 { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.help-subtitle { + font-size: 14px; + color: var(--text-secondary); +} + +.help-modal-body { + padding: 24px 32px; +} + +.help-section { + margin-bottom: 24px; +} + +.help-section:last-child { + margin-bottom: 0; +} + +.help-section h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.help-section h3 i { + width: 16px; + height: 16px; +} + +.help-info-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 16px; +} + +.help-email { + font-size: 14px; + color: var(--text-primary); + word-break: break-all; +} + +.copy-email-btn { + background: transparent; + border: none; + color: var(--accent-color); + cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.copy-email-btn:hover { + background: rgba(59, 130, 246, 0.1); + color: var(--accent-light); +} + +.copy-email-btn.copied { + color: var(--success-color); +} + +.copy-email-btn i { + width: 18px; + height: 18px; +} + +.help-whatsapp-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 12px 24px; + background: linear-gradient(135deg, #25D366 0%, #128C7E 100%); + color: white; + text-decoration: none; + border-radius: 8px; + font-weight: 500; + font-size: 14px; + transition: var(--transition); +} + +.help-whatsapp-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(37, 211, 102, 0.4); +} + +.help-whatsapp-btn i { + width: 18px; + height: 18px; +} + +.help-github-link { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); + text-decoration: none; + border-radius: 8px; + font-size: 14px; + transition: var(--transition); +} + +.help-github-link:hover { + border-color: var(--accent-color); + background: rgba(59, 130, 246, 0.05); +} + +.help-github-link i { + width: 16px; + height: 16px; + color: var(--text-secondary); +} + +.help-modal-footer { + padding: 16px 32px 32px; + text-align: center; +} + +.help-modal-footer p { + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.help-modal-footer i { + width: 16px; + height: 16px; + color: var(--danger-color); +} + +/* Mobile Responsive */ +@media (max-width: 480px) { + .help-modal-content { + max-width: 100%; + border-radius: 12px; + } + + .help-modal-header { + padding: 32px 24px 20px; + } + + .help-profile-img { + width: 80px; + height: 80px; + } + + .help-modal-header h2 { + font-size: 20px; + } + + .help-modal-body { + padding: 20px 24px; + } + + .help-modal-footer { + padding: 16px 24px 28px; + } +} +/* Filters Section */ +.filters-section { + display: flex; + gap: 20px; + margin-bottom: 30px; + padding: 20px; + background: var(--bg-card); + border-radius: 16px; + border: 1px solid var(--border-color); + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; +} + +.filter-group label { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); +} + +.filter-select { + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 14px; + font-family: 'Inter', sans-serif; + background: var(--bg-color); + color: var(--text-primary); + cursor: pointer; + transition: all 0.3s ease; +} + +.filter-select:hover { + border-color: var(--accent-color); +} + +.filter-select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Exercises Grid */ +.exercises-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 24px; + margin-bottom: 40px; +} + +.exercise-card { + background: var(--bg-card); + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border-color); + transition: all 0.3s ease; + cursor: pointer; +} + +.exercise-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 32px rgba(59, 130, 246, 0.2); + border-color: var(--accent-color); +} + +.exercise-card.completed { + border-color: var(--success-color); + background: linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, var(--bg-card) 100%); +} + +.exercise-card h3 { + margin: 0 0 12px 0; + color: var(--text-primary); + font-size: 20px; + font-weight: 700; +} + +.exercise-card p { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; + margin-bottom: 16px; +} + +.exercise-author { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-tertiary); + font-size: 13px; + font-style: italic; + margin-bottom: 12px; +} + +.exercise-author i { + width: 14px; + height: 14px; +} + +.exercise-theory { + margin-bottom: 12px; +} + +.exercise-theory a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(139, 92, 246, 0.1); + color: var(--accent-purple); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + text-decoration: none; + transition: all 0.3s ease; +} + +.exercise-theory a:hover { + background: rgba(139, 92, 246, 0.2); + transform: translateX(4px); +} + +.exercise-theory a i { + width: 14px; + height: 14px; +} + +.exercise-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.difficulty-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.difficulty-badge.easy { + background: rgba(34, 197, 94, 0.1); + color: var(--success-color); +} + +.difficulty-badge.medium { + background: rgba(251, 191, 36, 0.1); + color: var(--accent-yellow); +} + +.difficulty-badge.hard { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color); +} + +.points-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + background: rgba(59, 130, 246, 0.1); + color: var(--accent-color); +} + +.category-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + background: rgba(160, 160, 160, 0.1); + color: var(--text-secondary); +} + +.exercise-status { + display: flex; + align-items: center; + gap: 8px; + padding-top: 16px; + border-top: 1px solid var(--border-color); + font-size: 14px; + font-weight: 600; +} + +.exercise-status.completed { + color: var(--success-color); +} + +.exercise-status.pending { + color: var(--warning-color); +} + +/* List View Styles */ +.exercises-container.list-view { + display: flex; + flex-direction: column; + gap: 12px; +} + +.exercises-container.list-view .exercise-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + cursor: pointer; + border-left: 3px solid transparent; + transition: all 0.3s ease; +} + +.exercises-container.list-view .exercise-card:hover { + border-left-color: var(--accent-color); +} + +.exercises-container.list-view .exercise-card.completed { + border-left-color: var(--success-color); +} + +.exercises-container.list-view .exercise-card h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + flex: 0 0 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exercises-container.list-view .exercise-card p { + display: none; +} + +.exercises-container.list-view .exercise-author { + margin: 0; + flex: 0 0 auto; + font-size: 12px; +} + +.exercises-container.list-view .exercise-theory { + margin: 0; + flex: 0 0 auto; +} + +.exercises-container.list-view .exercise-theory a { + padding: 4px 10px; + font-size: 12px; +} + +.exercises-container.list-view .exercise-meta { + margin: 0; + gap: 8px; + flex: 1; + justify-content: flex-start; +} + +.exercises-container.list-view .exercise-meta .difficulty-badge, +.exercises-container.list-view .exercise-meta .points-badge, +.exercises-container.list-view .exercise-meta .category-badge { + padding: 4px 10px; + font-size: 11px; +} + +.exercises-container.list-view .exercise-status { + padding: 0; + border: none; + margin: 0; + flex: 0 0 auto; + font-size: 13px; +} + +/* Skeleton Loaders */ +.exercise-card.skeleton { + pointer-events: none; + animation: pulse 1.5s ease-in-out infinite; +} + +.skeleton-line { + height: 16px; + background: linear-gradient(90deg, var(--border-color) 25%, var(--bg-secondary) 50%, var(--border-color) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 4px; + margin-bottom: 12px; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* Exercise Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + position: relative; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.exercise-modal-content { + max-width: 1200px; + width: 95%; + padding: 32px; +} + +.modal-close { + position: absolute; + top: 16px; + right: 16px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: var(--transition); + font-size: 24px; + line-height: 1; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +.modal-close:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color); +} + +.exercise-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 2px solid var(--border-color); +} + +.exercise-header h2 { + margin: 0; + color: var(--text-primary); + font-size: 28px; + font-weight: 800; +} + +.exercise-badges { + display: flex; + gap: 12px; +} + +.exercise-description { + background: var(--bg-secondary); + border-left: 4px solid var(--accent-color); + padding: 20px; + border-radius: 8px; + margin-bottom: 24px; + line-height: 1.8; + color: var(--text-secondary); + white-space: pre-wrap; +} + +.exercise-description strong { + color: var(--text-primary); + display: block; + margin-top: 16px; + margin-bottom: 8px; +} + +/* Code Editor */ +.code-editor-container { + margin-bottom: 24px; +} + +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; + background: #1e293b; + color: white; + padding: 12px 20px; + border-radius: 12px 12px 0 0; + font-family: 'Courier New', monospace; + font-size: 14px; +} + +.editor-header span { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-reset { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: white; + font-size: 13px; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-reset:hover { + background: rgba(255, 255, 255, 0.2); +} + +.code-editor { + width: 100%; + min-height: 400px; + padding: 20px; + font-family: 'Courier New', 'Consolas', monospace; + font-size: 14px; + line-height: 1.6; + background: #0f172a; + color: #e2e8f0; + border: none; + border-radius: 0 0 12px 12px; + resize: vertical; + tab-size: 4; +} + +.code-editor:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +/* Exercise Actions */ +.exercise-actions { + display: flex; + gap: 16px; + justify-content: flex-end; + margin-bottom: 24px; +} + +.btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border-radius: 10px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-dark) 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: transparent; + color: var(--accent-color); + border: 2px solid var(--accent-color); +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(59, 130, 246, 0.1); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Results Section */ +.results-section { + background: var(--bg-secondary); + border-radius: 12px; + padding: 24px; + margin-top: 24px; +} + +.results-section.hidden { + display: none; +} + +.results-section h3 { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 20px 0; + color: var(--text-primary); + font-size: 20px; +} + +.result-summary { + display: flex; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.result-stat { + flex: 1; + min-width: 150px; + background: var(--bg-card); + padding: 16px; + border-radius: 10px; + text-align: center; + border: 1px solid var(--border-color); +} + +.result-stat-value { + font-size: 32px; + font-weight: 800; + margin-bottom: 4px; +} + +.result-stat-value.success { + color: var(--success-color); +} + +.result-stat-value.failed { + color: var(--danger-color); +} + +.result-stat-label { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.5px; +} + +.result-details { + background: var(--bg-card); + padding: 20px; + border-radius: 10px; + border-left: 4px solid var(--accent-color); +} + +.result-details.success { + border-left-color: var(--success-color); +} + +.result-details.failed { + border-left-color: var(--danger-color); +} + +.result-message { + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); + white-space: pre-wrap; + font-family: 'Courier New', monospace; +} + +/* Loading Overlay */ +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 20px; + z-index: 1000; +} + +.loading-overlay.hidden { + display: none; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 4px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-overlay p { + margin-top: 20px; + color: var(--text-primary); + font-weight: 600; +} + +/* Toast Notifications - Removing duplicate, using improved version above */ +/* Duplicate styles removed to avoid conflicts */ + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + grid-column: 1 / -1; +} + +.empty-state i { + width: 64px; + height: 64px; + color: var(--text-tertiary); + margin-bottom: 16px; +} + +.empty-state h3 { + margin: 0 0 8px 0; + color: var(--text-primary); + font-size: 20px; +} + +.empty-state p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; +} + +/* Responsive */ +@media (max-width: 768px) { + .exercises-container { + grid-template-columns: 1fr; + } + + .filters-section { + flex-direction: column; + gap: 16px; + } + + .filter-group { + min-width: 100%; + } + + .exercise-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .exercise-actions { + flex-direction: column; + } + + .btn { + width: 100%; + justify-content: center; + } + + .result-summary { + flex-direction: column; + } + + .exercise-modal-content { + padding: 20px; + } + + .toast { + left: 20px; + right: 20px; + min-width: auto; + } +} + +/* ================================ + EXERCISES PAGE ENHANCEMENTS +================================ */ + +/* Page Title Enhancement */ +#pageTitle { + position: relative; + display: inline-block; +} + +#pageTitle::after { + content: ''; + position: absolute; + bottom: -8px; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(90deg, var(--accent-color), var(--accent-yellow)); + border-radius: 2px; +} + +/* Content Wrapper for Exercises */ +.content-wrapper { + animation: fadeInUp 0.6s ease-out; +} + +/* Enhanced Filter Section */ +.filters-section { + animation: fadeInUp 0.5s ease-out 0.1s backwards; +} + +.filter-select option { + background: var(--bg-card); + color: var(--text-primary); +} + +/* Exercise Cards Enhanced Animations */ +.exercise-card { + animation: fadeInUp 0.6s ease-out backwards; +} + +.exercise-card:nth-child(1) { animation-delay: 0.1s; } +.exercise-card:nth-child(2) { animation-delay: 0.15s; } +.exercise-card:nth-child(3) { animation-delay: 0.2s; } +.exercise-card:nth-child(4) { animation-delay: 0.25s; } +.exercise-card:nth-child(5) { animation-delay: 0.3s; } +.exercise-card:nth-child(6) { animation-delay: 0.35s; } + +/* Badge Icons */ +.difficulty-badge i, +.points-badge i, +.category-badge i { + width: 14px; + height: 14px; +} + +/* Exercise Card Hover Effects */ +.exercise-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent-color), var(--accent-yellow)); + opacity: 0; + transition: opacity 0.3s ease; +} + +.exercise-card:hover::before { + opacity: 1; +} + +.exercise-card.completed::before { + background: linear-gradient(90deg, var(--success-color), #10b981); + opacity: 1; +} + +/* Code Editor Improvements */ +.code-editor { + font-family: 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', monospace; +} + +.code-editor::selection { + background: rgba(59, 130, 246, 0.3); +} + +/* Modal Backdrop Enhancement */ +.modal { + animation: fadeIn 0.3s ease-out; +} + +.modal.active .modal-content { + animation: slideUp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes slideUp { + from { + transform: translateY(50px) scale(0.95); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +/* ================================ + IMPROVED UI ENHANCEMENTS +================================ */ + +/* ================================ + GLOBAL HEADER - PROFESSIONAL & UNIFIED +================================ */ +.global-header { + background: rgba(0, 0, 0, 0.98); + backdrop-filter: blur(12px); + border-bottom: 1px solid #2a2a2a; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); +} + +.global-header-content { + max-width: 100%; + padding: 16px 45px; + display: flex; + align-items: center; + gap: 32px; +} + +/* Global Logo */ +.global-logo { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + flex-shrink: 0; + transition: opacity 0.3s ease; +} + +.global-logo:hover { + opacity: 0.8; +} + +.global-logo .logo-icon { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; +} + +.global-logo .logo-text { + font-size: 20px; + font-weight: 800; + color: #ffffff; + letter-spacing: -0.5px; +} + +/* Search Box in Global Header */ +.global-header .search-box { + display: flex; + align-items: center; + gap: 10px; + background: #0d0d0d; + border: 1px solid #444444; + border-radius: 10px; + padding: 10px 16px; + transition: all 0.3s ease; + flex: 1; + max-width: 500px; + height: 42px; +} + +.global-header .search-box:focus-within { + border-color: var(--accent-color); + background: #1a1a1a; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.global-header .search-box i { + color: #9ca3af; + flex-shrink: 0; + width: 18px; + height: 18px; + transition: color 0.3s ease; +} + +.global-header .search-box:focus-within i { + color: var(--accent-color); +} + +.global-header .search-box input { + border: none; + background: transparent; + color: #ffffff; + font-size: 14px; + width: 100%; + outline: none; + font-weight: 400; +} + +.global-header .search-box input::placeholder { + color: #6b7280; +} + +/* Header Actions */ +.global-header .header-actions { + display: flex; + align-items: center; + gap: 16px; + margin-left: auto; + flex-shrink: 0; +} + +/* Help Button in Global Header */ +.global-header .help-btn { + background: #0d0d0d; + border: 1px solid #444444; + color: #9ca3af; + cursor: pointer; + padding: 0; + border-radius: 10px; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + flex-shrink: 0; +} + +.global-header .help-btn:hover { + background: #1a1a1a; + border-color: var(--accent-color); + color: var(--accent-color); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.global-header .help-btn i { + width: 20px; + height: 20px; +} + +/* User Profile in Global Header */ +.header-user-profile { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: #0d0d0d; + border: 1px solid #444444; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s ease; +} + +.header-user-profile:hover { + background: #1a1a1a; + border-color: #555555; +} + +.header-user-profile .user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--accent-color); +} + +.header-user-profile .user-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.header-user-profile .user-name { + font-size: 14px; + font-weight: 600; + color: #ffffff; + line-height: 1; +} + +.header-user-profile .logout-link { + font-size: 12px; + color: #6b7280; + text-decoration: none; + transition: color 0.3s ease; + line-height: 1; +} + +.header-user-profile .logout-link:hover { + color: var(--accent-color); +} + +/* ================================ + PAGE TITLE SECTION - CLEAR HIERARCHY +================================ */ +.page-title-section { + padding: 32px 45px 24px 45px; + border-bottom: 1px solid #333333; + background: transparent; +} + +.page-title { + font-size: 32px; + font-weight: 900; + color: #ffffff; + margin: 0 0 8px 0; + line-height: 1.2; + letter-spacing: -1px; +} + +.page-title .accent { + background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.page-subtitle { + font-size: 15px; + color: #a0a0a0; + margin: 0; + font-weight: 400; + line-height: 1.5; +} + +.help-btn:hover { + background: #202020; + border-color: var(--accent-color); + color: var(--accent-color); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.help-btn i { + width: 20px; + height: 20px; +} + +/* Responsive Header */ +@media (max-width: 1200px) { + .header-content { + max-width: 100%; + padding: 20px 32px; + } + + .search-box { + min-width: 220px; + } +} + +@media (max-width: 968px) { + .header-content { + flex-direction: column; + align-items: flex-start; + gap: 20px; + } + + .header-title-section { + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + } + + .header-divider { + display: none; + } + + .header-actions { + width: 100%; + } + + .search-box { + flex: 1; + min-width: auto; + } + + .main-header h1 { + font-size: 24px; + } +} + +@media (max-width: 480px) { + .header-content { + padding: 16px 20px; + } + + .main-header h1 { + font-size: 20px; + } + + .header-tagline { + font-size: 13px; + } + + .search-box { + min-width: auto; + padding: 8px 12px; + } + + .help-btn { + width: 38px; + height: 38px; + } +} + +/* Stats Bar */ +/* ================================ + REFINED STATS BAR - LIGHTER & CLEANER +================================ */ +.stats-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 24px; + padding: 32px 45px; + background: transparent; + border-bottom: 1px solid #333333; +} + +.stat-item { + display: flex; + align-items: center; + gap: 16px; + padding: 0; + background: transparent; + border: 1px solid #333333; + border-radius: 12px; + padding: 18px 20px; + transition: all 0.3s ease; + position: relative; + overflow: visible; +} + +.stat-item:hover { + transform: translateY(-2px); + border-color: #444444; + background: rgba(255, 255, 255, 0.02); +} + +.stat-icon { + width: 44px; + height: 44px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.3s ease; +} + +.stat-item:nth-child(1) .stat-icon { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; +} + +.stat-item:nth-child(2) .stat-icon { + background: rgba(251, 191, 36, 0.12); + color: #fbbf24; +} + +.stat-item:nth-child(3) .stat-icon { + background: rgba(59, 130, 246, 0.12); + color: #3b82f6; +} + +.stat-item:nth-child(4) .stat-icon { + background: rgba(168, 85, 247, 0.12); + color: #a855f7; +} + +.stat-item:hover .stat-icon { + transform: scale(1.05); +} + +.stat-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.stat-value { + font-size: 28px; + font-weight: 900; + color: #ffffff; + line-height: 1; +} + +.stat-label { + font-size: 13px; + color: #9ca3af; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ================================ + REFINED FILTERS SECTION - LIGHTER DESIGN +================================ */ +.filters-section { + padding: 20px 45px; + background: transparent; + border-bottom: 1px solid #333333; + position: relative; +} + +/* Filters Wrapper - Single Row Layout */ +.filters-wrapper { + display: flex; + align-items: center; + gap: 32px; + justify-content: space-between; +} + +.filters-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 700; + color: #ffffff; + text-transform: uppercase; + letter-spacing: 1px; + flex-shrink: 0; +} + +.filters-title i { + width: 18px; + height: 18px; + color: var(--accent-color); +} + +/* Filters Container - Horizontal Layout (Center) */ +.filters-container { + display: flex; + align-items: flex-end; + gap: 28px; + flex: 1; + justify-content: flex-start; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 10px; + flex: 0 1 auto; +} + +.filter-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.filter-label i { + width: 14px; + height: 14px; + color: var(--accent-color); +} + +/* Clear Button - Right Aligned */ +.btn-clear-filters { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + border: 1px solid #444444; + border-radius: 8px; + color: #9ca3af; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; +} + +.btn-clear-filters:hover { + background: rgba(239, 68, 68, 0.1); + border-color: var(--danger-color); + color: var(--danger-color); + transform: translateY(-1px); +} + +.btn-clear-filters i { + width: 14px; + height: 14px; +} + +.btn-clear-filters span { + font-weight: 600; +} + +/* View Toggle Buttons */ +.view-toggle { + display: flex; + gap: 4px; + background: var(--bg-secondary); + border-radius: 10px; + padding: 4px; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.view-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.3s ease; +} + +.view-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.view-btn.active { + background: var(--accent-color); + color: white; +} + +.view-btn i { + width: 18px; + height: 18px; +} + +/* Toggle Button Groups */ +.toggle-group { + display: flex; + gap: 0; + background: var(--bg-secondary); + border-radius: 10px; + padding: 4px; + border: 1px solid var(--border-color); +} + +.toggle-btn { + padding: 8px 16px; + background: transparent; + border: none; + border-radius: 7px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + position: relative; +} + +.toggle-btn::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 7px; + background: linear-gradient(135deg, var(--accent-color), #5b8fd9); + opacity: 0; + transition: opacity 0.3s ease; + z-index: -1; +} + +.toggle-btn:hover:not(.active) { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} + +.toggle-btn.active { + color: #ffffff; + font-weight: 700; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); +} + +.toggle-btn.active::before { + opacity: 1; +} + +/* Dropdown Select (para Categoría) */ +.filter-select { + padding: 10px 40px 10px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + appearance: none; + min-width: 200px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +.filter-select:hover { + border-color: var(--accent-color); + background-color: rgba(59, 130, 246, 0.05); +} + +.filter-select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.filter-select option { + background: var(--bg-card); + color: var(--text-primary); + padding: 10px; +} + +/* Responsive Filters */ +@media (max-width: 1200px) { + .filters-wrapper { + gap: 24px; + } + + .filters-container { + gap: 20px; + } + + .toggle-btn { + padding: 7px 12px; + font-size: 12px; + } +} + +@media (max-width: 968px) { + .filters-wrapper { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .filters-container { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .filter-group { + width: 100%; + } + + .toggle-group { + width: 100%; + justify-content: space-between; + } + + .toggle-btn { + flex: 1; + padding: 10px 8px; + font-size: 12px; + } + + .filter-select { + width: 100%; + min-width: auto; + } + + .btn-clear-filters { + align-self: flex-start; + } +} + +@media (max-width: 768px) { + .filters-section { + padding: 16px 20px; + } +} + +/* Enhanced Exercise Cards */ +.exercises-container { + padding: 32px 45px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 28px; +} + +.exercise-card { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 18px; + padding: 28px; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + overflow: hidden; +} + +.exercise-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--accent-color), var(--accent-yellow)); + opacity: 0; + transition: all 0.3s ease; +} + +.exercise-card::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 60%); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.exercise-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 20px 60px rgba(59, 130, 246, 0.25); + border-color: var(--accent-color); +} + +.exercise-card:hover::before { + opacity: 1; +} + +.exercise-card:hover::after { + opacity: 1; +} + +/* Professional Completed State - Subtle Left Border */ +.exercise-card.completed { + border-color: var(--border-color); + border-left: 4px solid #22c55e; + background: var(--bg-card); +} + +.exercise-card.completed::before { + display: none; +} + +.exercise-card h3 { + margin: 0 0 16px 0; + color: var(--text-primary); + font-size: 22px; + font-weight: 800; + line-height: 1.3; + position: relative; + z-index: 1; +} + +.exercise-card p { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.7; + margin-bottom: 20px; + position: relative; + z-index: 1; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.exercise-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 20px; + position: relative; + z-index: 1; +} + +.difficulty-badge, +.points-badge, +.category-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 10px; + font-size: 13px; + font-weight: 700; + transition: all 0.3s ease; +} + +.difficulty-badge i, +.points-badge i, +.category-badge i { + width: 14px; + height: 14px; +} + +.difficulty-badge.easy { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(16, 185, 129, 0.15) 100%); + color: var(--success-color); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.difficulty-badge.medium { + background: linear-gradient(135deg, rgba(251, 191, 36, 0.15) 0%, rgba(245, 158, 11, 0.15) 100%); + color: var(--accent-yellow); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.difficulty-badge.hard { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(220, 38, 38, 0.15) 100%); + color: var(--danger-color); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.points-badge { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(37, 99, 235, 0.15) 100%); + color: var(--accent-color); + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.category-badge { + background: rgba(160, 160, 160, 0.1); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.exercise-status { + display: flex; + align-items: center; + gap: 10px; + padding-top: 20px; + border-top: 1px solid var(--border-color); + font-size: 14px; + font-weight: 700; + position: relative; + z-index: 1; +} + +.exercise-status i { + width: 18px; + height: 18px; +} + +.exercise-status.completed { + color: var(--success-color); +} + +.exercise-status.pending { + color: var(--warning-color); +} + +/* Mobile Responsiveness */ +@media (max-width: 1024px) { + .main-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding-top: 20px; + padding-bottom: 20px; + } + + .header-actions { + width: 100%; + } + + .search-box { + flex: 1; + min-width: auto; + } + + .stats-bar { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + padding: 20px; + } + + .filters-section { + padding: 20px; + } + + .filter-controls { + grid-template-columns: 1fr; + } + + .exercises-container { + padding: 24px 20px; + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .main-header h1 { + font-size: 24px; + } + + .stats-bar { + grid-template-columns: 1fr; + } + + .stat-item { + padding: 16px; + } + + .stat-value { + font-size: 20px; + } +} + +/* ================================ + ENHANCED MODAL +================================ */ + +.exercise-modal-content { + max-width: 1400px; + padding: 40px; +} + +.exercise-header h2 { + font-size: 32px; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--accent-light) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.code-editor-container { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border-radius: 12px; + overflow: hidden; +} + +.code-editor { + min-height: 450px; + font-size: 15px; + line-height: 1.8; +} + +.btn { + position: relative; + overflow: hidden; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; +} + +.btn:hover::before { + width: 300px; + height: 300px; +} + +.btn i { + position: relative; + z-index: 1; +} + +.btn span { + position: relative; + z-index: 1; +} + +/* Success Animation for Completed Exercises */ +@keyframes successPulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); + } + 50% { + box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); + } +} + +.exercise-card.completed { + animation: fadeInUp 0.6s ease-out backwards, successPulse 2s ease-in-out 0.8s; +} + +/* Loading States */ +.btn.loading { + position: relative; + color: transparent; + pointer-events: none; +} + +.btn.loading::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + top: 50%; + left: 50%; + margin-left: -8px; + margin-top: -8px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.6s linear infinite; + color: white; +} + +/* Tooltip for Exercise Cards */ +.exercise-card[data-tooltip] { + position: relative; +} + +/* Stats Counter Animation */ +@keyframes countUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.result-stat-value { + animation: countUp 0.6s ease-out; +} + +/* Gradient Text for Important Elements */ +.exercise-header h2 { + background: linear-gradient(135deg, var(--text-primary) 0%, var(--accent-light) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ================================ + RESPONSIVE - GLOBAL HEADER & LAYOUT +================================ */ + +/* Tablet Large (≤1200px) */ +@media (max-width: 1200px) { + .global-header-content { + gap: 20px; + padding: 14px 32px; + } + + .global-header .search-box { + max-width: 400px; + } + + .page-title-section { + padding: 28px 32px 20px 32px; + } + + .stats-bar { + padding: 28px 32px; + gap: 20px; + } + + .filters-section { + padding: 18px 32px; + } + + .exercises-container { + padding: 28px 32px; + gap: 24px; + } +} + +/* Tablet (≤1024px) */ +@media (max-width: 1024px) { + /* Global Header */ + .global-header-content { + gap: 16px; + } + + .global-header .search-box { + max-width: 350px; + } + + .filters-wrapper { + gap: 20px; + } + + .filters-container { + gap: 16px; + } +} + +/* Tablet Small / Large Phone (≤968px) */ +@media (max-width: 968px) { + /* Filters - Stack Vertical */ + .filters-wrapper { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .filters-container { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .filter-group { + width: 100%; + } + + .toggle-group { + width: 100%; + justify-content: space-between; + } + + .toggle-btn { + flex: 1; + padding: 10px 8px; + font-size: 12px; + } + + .filter-select { + width: 100%; + min-width: auto; + } + + .btn-clear-filters { + align-self: flex-start; + width: auto; + } + + /* Stats - 2 columns */ + .stats-bar { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + /* Exercises grid - 2 columns */ + .exercises-container { + grid-template-columns: repeat(2, 1fr); + gap: 20px; + } +} + +/* Mobile (≤768px) */ +@media (max-width: 768px) { + /* Global Header - Diseño Limpio en 3 Filas */ + .global-header { + border-bottom: 1px solid #2a2a2a; + padding: 8px 0; + } + + .global-header-content { + padding: 0 16px; + flex-direction: column; + align-items: center; + gap: 8px; + } + + /* Fila 1: Logo Centrado */ + .global-logo { + order: 1; + margin: 0; + } + + .global-logo .logo-icon { + width: 36px; + height: 36px; + } + + .global-logo .logo-text { + font-size: 20px; + } + + /* Fila 2: Search Box */ + .global-header .search-box { + order: 2; + width: 100%; + max-width: none; + margin: 0; + } + + .global-header .search-box input { + font-size: 14px; + padding: 10px 16px 10px 40px; + } + + /* Fila 3: Actions Centradas */ + .header-actions { + order: 3; + position: relative; + gap: 12px; + top: auto; + right: auto; + } + + .global-header .help-btn { + width: 40px; + height: 40px; + } + + .header-user-profile { + padding: 6px 12px; + gap: 8px; + } + + .header-user-profile .user-avatar { + width: 32px; + height: 32px; + } + + .header-user-profile .user-info { + display: flex; + } + + .header-user-profile .user-name { + font-size: 13px; + max-width: 100px; + } + + /* Page Title */ + .page-title-section { + padding: 20px 20px 16px 20px; + } + + .page-title { + font-size: 24px; + letter-spacing: -0.5px; + } + + .page-subtitle { + font-size: 14px; + } + + /* Stats Bar */ + .stats-bar { + padding: 20px 20px; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .stat-item { + padding: 14px 16px; + } + + .stat-icon { + width: 40px; + height: 40px; + } + + .stat-value { + font-size: 24px; + } + + .stat-label { + font-size: 11px; + } + + /* Filters */ + .filters-section { + padding: 16px 20px; + } + + .filters-title { + font-size: 12px; + } + + .filter-label { + font-size: 10px; + } + + /* Exercises Container */ + .exercises-container { + padding: 20px; + grid-template-columns: 1fr; + gap: 16px; + } + + /* List View Adjustments for Mobile */ + .exercises-container.list-view .exercise-card { + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 16px; + } + + .exercises-container.list-view .exercise-card h3 { + flex: 1; + font-size: 14px; + white-space: normal; + } + + .exercises-container.list-view .exercise-meta { + flex-wrap: wrap; + width: 100%; + } + + .exercises-container.list-view .exercise-author, + .exercises-container.list-view .exercise-theory, + .exercises-container.list-view .exercise-status { + width: 100%; + } + + /* Exercise Cards */ + .exercise-card { + padding: 20px; + } + + .exercise-card h3 { + font-size: 18px; + } + + .exercise-card p { + font-size: 13px; + } +} + +/* Mobile Small (≤480px) */ +@media (max-width: 480px) { + /* Mobile Sidebar Toggle - Smaller */ + .mobile-sidebar-toggle { + top: 8px; + left: 8px; + width: 40px; + height: 40px; + } + + .mobile-sidebar-toggle i { + width: 20px; + height: 20px; + } + + /* Global Header - Ultra Compacto */ + .global-header-content { + padding: 8px 12px; + } + + .global-logo { + gap: 8px; + } + + .global-logo .logo-icon { + width: 28px; + height: 28px; + } + + .global-logo .logo-text { + font-size: 16px; + } + + .header-actions { + top: 8px; + right: 12px; + gap: 8px; + } + + .header-user-profile .user-avatar { + width: 28px; + height: 28px; + } + + .global-header .help-btn { + width: 36px; + height: 36px; + } + + .global-header .search-box { + padding: 8px 12px; + font-size: 13px; + } + + /* Page Title */ + .page-title-section { + padding: 16px 16px 12px 16px; + } + + .page-title { + font-size: 20px; + } + + .page-subtitle { + font-size: 13px; + } + + /* Stats Bar - Single Column */ + .stats-bar { + padding: 16px; + grid-template-columns: 1fr; + gap: 10px; + } + + .stat-item { + padding: 12px 14px; + } + + .stat-icon { + width: 36px; + height: 36px; + } + + .stat-value { + font-size: 22px; + } + + .stat-label { + font-size: 10px; + } + + /* Filters */ + .filters-section { + padding: 12px 16px; + } + + .filters-title { + font-size: 11px; + } + + .toggle-btn { + padding: 8px 6px; + font-size: 11px; + } + + .filter-select { + font-size: 12px; + padding: 8px 32px 8px 12px; + } + + .btn-clear-filters { + padding: 6px 10px; + font-size: 11px; + } + + /* Exercises Container */ + .exercises-container { + padding: 16px; + gap: 12px; + } + + /* Exercise Cards */ + .exercise-card { + padding: 16px; + border-radius: 14px; + } + + .exercise-card h3 { + font-size: 16px; + margin-bottom: 12px; + } + + .exercise-card p { + font-size: 12px; + margin-bottom: 16px; + } + + .exercise-card .tags { + gap: 6px; + } + + .exercise-card .tag { + padding: 4px 8px; + font-size: 10px; + } + + /* Modal Improvements */ + .exercise-modal-content { + padding: 16px; + max-width: 100%; + margin: 0; + border-radius: 0; + max-height: 100vh; + } + + .modal-header { + padding: 16px; + margin: -16px -16px 16px -16px; + } + + .modal-header h2 { + font-size: 18px; + } + + .close-modal { + width: 32px; + height: 32px; + } + + .code-editor-container { + margin: 12px 0; + } + + #codeEditor { + font-size: 12px; + min-height: 250px; + } + + .modal-actions { + padding: 12px; + margin: 16px -16px -16px -16px; + flex-direction: column; + gap: 8px; + } + + .btn { + width: 100%; + justify-content: center; + } + + /* Help Modal */ + .help-modal-content { + padding: 16px; + max-width: 100%; + } + + .help-modal h2 { + font-size: 20px; + } + + .help-step h3 { + font-size: 14px; + } + + /* Toast Notifications */ + .toast { + bottom: 16px; + left: 12px; + right: 12px; + padding: 10px 12px; + } + + .toast-title { + font-size: 12px; + } + + .toast-message { + font-size: 11px; + } +} + +/* Mobile Landscape Optimization (≤896px height) */ +@media (max-height: 896px) and (orientation: landscape) { + .exercise-modal-content { + max-height: 90vh; + overflow-y: auto; + } + + #codeEditor { + min-height: 200px; + } + + .stats-bar { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Smooth Scrollbar for Modal */ +.modal-content::-webkit-scrollbar { + width: 8px; +} + +.modal-content::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +.modal-content::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.modal-content::-webkit-scrollbar-thumb:hover { + background: var(--accent-color); +} + +/* Focus States for Accessibility */ +.filter-select:focus-visible, +.btn:focus-visible, +.action-btn:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +/* Print Styles */ +@media print { + .sidebar, + .header-actions, + .filters-section, + .mobile-sidebar-toggle, + .sidebar-overlay, + .modal, + .toast { + display: none !important; + } + + .main-content { + margin-left: 0; + width: 100%; + } + + .exercise-card { + break-inside: avoid; + page-break-inside: avoid; + } +} diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 0000000..365a53a --- /dev/null +++ b/src/css/index.css @@ -0,0 +1,588 @@ +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Variables de diseño premium */ +:root { + --font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --bg-overlay-start: rgba(0, 0, 0, 0.50); + --bg-overlay-mid: rgba(0, 0, 0, 0.30); + --bg-overlay-end: rgba(0, 0, 0, 0.70); + --java-orange: #ED8B00; + --java-orange-light: #FFA726; + --java-orange-glow: rgba(237, 139, 0, 0.25); + --java-blue: #007396; + --java-blue-light: #0097C8; + --java-blue-glow: rgba(0, 115, 150, 0.25); + --accent-purple-glow: var(--java-blue-glow); + --text-white: #ffffff; + --text-muted: rgba(255, 255, 255, 0.90); + --text-subtle: rgba(255, 255, 255, 0.75); +} + +body, html { + height: 100%; + width: 100%; + background: #000; + color: var(--text-white); + font-family: var(--font-primary); + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + animation: pageLoad 0.8s ease-out; +} + +@keyframes pageLoad { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Animación fadeInUp para elementos */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 1. Contenedor Hero (Pantalla Completa) */ +.hero-container { + height: 100vh; + width: 100vw; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-image: url(../assets/one.jpg); + background-size: cover; + background-position: center; + background-attachment: fixed; +} + +/* 2. Capa de Oscuridad Mejorada Premium */ +.hero-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, + var(--bg-overlay-start) 0%, + var(--bg-overlay-mid) 50%, + var(--bg-overlay-end) 100%); + z-index: 1; + backdrop-filter: blur(1px); + -webkit-backdrop-filter: blur(1px); +} + +/* Efecto de vignette premium con gradiente radial */ +.hero-overlay::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.35) 100%); + pointer-events: none; +} + +/* 3. Navegación Superior Premium */ +.hero-nav { + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 2; + display: flex; + justify-content: space-between; + align-items: center; + padding: 50px 80px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, transparent 100%); +} + +/* Contenedor de botones del nav */ +.nav-buttons { + display: flex; + gap: 1rem; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 16px; + text-decoration: none; + color: #fff; + font-size: 26px; + font-weight: 800; + letter-spacing: -0.8px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.logo span { + background: linear-gradient(135deg, #ffffff 0%, #e0e7ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.logo:hover { + transform: translateY(-3px); +} + +.logo-icon { + width: 42px; + height: 42px; + border-radius: 12px; + transition: all 0.3s ease; +} + +.logo:hover .logo-icon { + transform: scale(1.05); +} + +/* 4. Contenido Principal Premium */ +.hero-content { + position: absolute; + bottom: 100px; + right: 80px; + z-index: 2; + width: 100%; + max-width: 650px; + text-align: right; +} + +.hero-content h1 { + font-size: 82px; + font-weight: 900; + line-height: 1; + color: #fff; + margin-bottom: 32px; + letter-spacing: -2.5px; + text-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); +} + +.hero-content h1 span { + display: block; + background: linear-gradient(135deg, #ffffff 0%, #d1d5db 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Línea divisora premium con animación */ +.title-divider { + height: 6px; + width: 140px; + background: linear-gradient(90deg, var(--java-blue) 0%, var(--java-blue-light) 50%, var(--java-orange) 100%); + margin-left: auto; + margin-bottom: 32px; + border-radius: 10px; + box-shadow: 0 0 25px var(--java-blue-glow), + 0 4px 12px var(--java-blue-glow); + animation: divider-glow 4s ease-in-out infinite; +} + +@keyframes divider-glow { + 0%, 100% { + box-shadow: 0 0 25px var(--java-blue-glow), + 0 4px 12px var(--java-blue-glow); + opacity: 1; + } + 50% { + box-shadow: 0 0 40px rgba(0, 115, 150, 0.4), + 0 6px 20px rgba(0, 115, 150, 0.35); + opacity: 0.95; + } +} + +.hero-content p { + font-size: 21px; + color: var(--text-muted); + line-height: 1.65; + margin-bottom: 40px; + font-weight: 400; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.6); + letter-spacing: 0.3px; +} + +/* 5. Botones Premium con Efectos Sofisticados */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 18px 40px; + font-size: 16px; + font-weight: 700; + text-decoration: none; + border-radius: 16px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + border: 2px solid transparent; + position: relative; + letter-spacing: 0.5px; +} + +/* Efecto de brillo premium en hover - SOLO para botones genéricos */ +.btn-primary::before, +.btn-secondary::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.btn-primary:hover::before, +.btn-secondary:hover::before { + width: 350px; + height: 350px; +} + +/* Botón del Nav - Pequeño y discreto */ +.btn-nav-signin { + padding: 11px 24px; + font-size: 14px; + font-weight: 600; + background: rgba(255, 255, 255, 0.06); + color: var(--text-muted); + border-color: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.btn-nav-signin:focus { + outline: none; + box-shadow: 0 0 0 3px var(--accent-purple-glow); + border-color: rgba(168, 85, 247, 0.3); +} + +.btn-nav-signin:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.3); + color: var(--text-white); + transform: translateY(-1px); +} + +/* Botones del Nav Superior */ +.btn-nav-secondary { + padding: 12px 24px; + font-size: 15px; + font-weight: 600; + background: rgba(255, 255, 255, 0.06); + color: var(--text-muted); + border-color: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.btn-nav-secondary:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.3); + color: var(--text-white); + transform: translateY(-2px); +} + +.btn-nav-primary { + padding: 12px 28px; + font-size: 15px; + font-weight: 700; + background: linear-gradient(135deg, #ffffff 0%, #f8f8f8 100%); + color: #000; + border: 2px solid transparent; + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.25); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.btn-nav-primary:hover { + background: linear-gradient(135deg, #fefefe 0%, #ffffff 100%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 255, 255, 0.35); +} + +/* Botón Primario Genérico (Fallback) */ +.btn-primary { + background: linear-gradient(135deg, #ffffff 0%, #f8f8f8 100%); + color: #000; + box-shadow: 0 6px 20px rgba(255, 255, 255, 0.25), + 0 3px 10px rgba(255, 255, 255, 0.15); + position: relative; + overflow: hidden; +} + +.btn-primary:hover { + background: linear-gradient(135deg, #fefefe 0%, #ffffff 100%); + transform: translateY(-4px); + box-shadow: 0 12px 35px rgba(255, 255, 255, 0.35), + 0 6px 20px rgba(255, 255, 255, 0.2); +} + +.btn-primary:active { + transform: translateY(-2px); +} + +/* Botón Secundario Genérico Glass (Fallback) */ +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: #fff; + border-color: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-3px); + box-shadow: 0 8px 25px rgba(255, 255, 255, 0.15); +} + +/* 6. Animaciones de Entrada Mejoradas */ +.logo, +.hero-nav .btn-secondary, +.hero-content h1, +.title-divider, +.hero-content p, +#hero-button .btn-primary { + opacity: 0; + transform: translateY(20px); + transition: all 1s cubic-bezier(0.16, 1, 0.3, 1); +} + +.logo.visible { + opacity: 1; + transform: translateY(0); + transition-delay: 0.1s; +} + +.hero-nav .btn-secondary.visible { + opacity: 1; + transform: translateY(0); + transition-delay: 0.2s; +} + +.hero-content h1.visible { + opacity: 1; + transform: translateY(0); + transition-delay: 0.3s; +} + +.title-divider.visible { + opacity: 1; + transform: translateY(0); + transition-delay: 0.5s; +} + +.hero-content p.visible { + opacity: 1; + transform: translateY(0); + transition-delay: 0.7s; +} + +#hero-button .btn-primary.visible { + opacity: 1; + transform: translateY(0); + transition-delay: 0.9s; +} + +/* Decoración adicional - Efecto de partículas (opcional) */ +.hero-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 20% 50%, rgba(124, 58, 237, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(167, 139, 250, 0.1) 0%, transparent 50%); + z-index: 1; + pointer-events: none; +} + +/* ================================ + RESPONSIVE DESIGN MEJORADO +================================ */ + +/* --- Tablet (Pantallas medianas) --- */ +@media (max-width: 768px) { + .hero-nav { + padding: 35px 40px; + } + + .hero-content { + bottom: auto; + right: auto; + left: 50%; + top: 52%; + transform: translate(-50%, -50%); + width: 88%; + max-width: 600px; + text-align: center; + } + + .hero-content h1 { + font-size: 60px; + letter-spacing: -1.5px; + } + + .title-divider { + margin-left: auto; + margin-right: auto; + width: 100px; + } + + .hero-content p { + font-size: 17px; + max-width: 480px; + margin-left: auto; + margin-right: auto; + } +} + +/* --- Móvil (Pantallas pequeñas) --- */ +@media (max-width: 480px) { + body, html { + overflow-y: auto; + } + + .hero-container { + min-height: 100vh; + height: auto; + background-attachment: scroll; + } + + .hero-nav { + padding: 24px; + backdrop-filter: blur(20px); + background: rgba(0, 0, 0, 0.3); + } + + .logo { + font-size: 19px; + gap: 12px; + } + + .logo-icon { + width: 32px; + height: 32px; + } + + .nav-buttons { + gap: 0.5rem; + } + + .btn-nav-secondary, + .btn-nav-primary { + padding: 9px 16px; + font-size: 13px; + } + + .hero-content { + top: 50%; + transform: translate(-50%, -50%); + width: 88%; + padding: 30px 0; + } + + .hero-content h1 { + font-size: 40px; + margin-bottom: 20px; + line-height: 1.1; + letter-spacing: -1px; + } + + .title-divider { + width: 80px; + height: 4px; + margin-bottom: 20px; + } + + .hero-content p { + font-size: 15px; + line-height: 1.55; + margin-bottom: 20px; + } +} + +/* --- Móvil Extra Pequeño --- */ +@media (max-width: 360px) { + .hero-nav { + padding: 20px; + } + + .hero-content { + width: 92%; + } + + .hero-content h1 { + font-size: 36px; + margin-bottom: 18px; + } + + .title-divider { + width: 70px; + margin-bottom: 18px; + } + + .hero-content p { + font-size: 14px; + margin-bottom: 24px; + } + + #hero-button .btn-primary { + padding: 16px 24px; + font-size: 15px; + max-width: 280px; + } +} + +/* --- Desktop Grande (mejoras adicionales) --- */ +@media (min-width: 1440px) { + .hero-nav { + padding: 50px 80px; + } + + .hero-content { + bottom: 100px; + right: 80px; + max-width: 650px; + } + + .hero-content h1 { + font-size: 84px; + margin-bottom: 32px; + } + + .title-divider { + width: 140px; + height: 6px; + } + + .hero-content p { + font-size: 21px; + margin-bottom: 40px; + } + + .btn-primary { + padding: 18px 36px; + font-size: 17px; + } +} \ No newline at end of file diff --git a/src/css/settings.css b/src/css/settings.css new file mode 100644 index 0000000..e263184 --- /dev/null +++ b/src/css/settings.css @@ -0,0 +1,513 @@ +/* ================================ + SETTINGS PAGE STYLES +================================ */ + +.settings-container { + max-width: 900px; + margin: 0 auto; + animation: fadeInUp 0.6s ease-out; +} + +.settings-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 18px; + padding: 32px; + margin-bottom: 28px; + transition: var(--transition); +} + +.settings-section:hover { + border-color: rgba(59, 130, 246, 0.3); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +/* Section Header */ +.section-header { + display: flex; + align-items: flex-start; + gap: 20px; + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-color); +} + +.section-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: rgba(59, 130, 246, 0.15); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-color); + flex-shrink: 0; +} + +.section-icon i { + width: 28px; + height: 28px; +} + +.section-icon.danger { + background: rgba(239, 68, 68, 0.15); + color: var(--danger-color); +} + +.section-info h2 { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.section-info p { + font-size: 14px; + color: var(--text-secondary); +} + +/* Forms */ +.settings-form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.form-group input, +.form-group select { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 14px 16px; + font-size: 15px; + color: var(--text-primary); + transition: var(--transition); + font-family: inherit; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent-color); + background: var(--bg-card); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-group input::placeholder { + color: var(--text-tertiary); +} + +.form-group select { + cursor: pointer; +} + +/* Password Input Wrapper */ +.password-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.password-input-wrapper input { + flex: 1; + padding-right: 48px; +} + +.toggle-password { + position: absolute; + right: 12px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + border-radius: 6px; +} + +.toggle-password:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} + +.toggle-password i { + width: 18px; + height: 18px; +} + +/* Error Messages */ +.error-message { + font-size: 13px; + color: var(--danger-color); + display: none; +} + +.error-message.active { + display: block; +} + +.form-group input.error, +.form-group select.error { + border-color: var(--danger-color); +} + +/* Password Strength Indicator */ +.password-strength { + display: none; +} + +.password-strength.active { + display: block; +} + +.strength-bar { + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; + margin-bottom: 8px; +} + +.strength-fill { + height: 100%; + width: 0; + transition: all 0.3s ease; + border-radius: 3px; +} + +.strength-fill.weak { + width: 33%; + background: var(--danger-color); +} + +.strength-fill.medium { + width: 66%; + background: var(--warning-color); +} + +.strength-fill.strong { + width: 100%; + background: var(--success-color); +} + +.strength-text { + font-size: 13px; + font-weight: 600; +} + +.strength-text.weak { + color: var(--danger-color); +} + +.strength-text.medium { + color: var(--warning-color); +} + +.strength-text.strong { + color: var(--success-color); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px 28px; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + font-family: inherit; +} + +.btn i { + width: 18px; + height: 18px; +} + +.btn-primary { + background: var(--accent-color); + color: white; +} + +.btn-primary:hover { + background: var(--accent-light); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-card); + border-color: var(--accent-color); +} + +.btn-danger { + background: var(--danger-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); +} + +.btn-danger:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 8px; +} + +/* Danger Zone */ +.danger-zone { + border-color: rgba(239, 68, 68, 0.3); +} + +.danger-zone:hover { + border-color: rgba(239, 68, 68, 0.5); +} + +.danger-actions { + display: flex; + flex-direction: column; + gap: 20px; +} + +.danger-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 20px; + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 12px; +} + +.danger-item-info h3 { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.danger-item-info p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + inset: 0; + z-index: 10000; + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 18px; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: slideInFromRight 0.3s ease-out; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.modal-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: var(--transition); +} + +.modal-close:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); +} + +.modal-body { + padding: 24px; +} + +.modal-body p { + font-size: 15px; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 16px; +} + +.warning-list { + list-style: none; + padding: 0; + margin: 16px 0; +} + +.warning-list li { + padding: 8px 0; + padding-left: 28px; + position: relative; + color: var(--text-secondary); +} + +.warning-list li::before { + content: '❌'; + position: absolute; + left: 0; + font-size: 14px; +} + +.warning-text { + color: var(--danger-color); + font-weight: 600; + margin-top: 16px; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 24px; + border-top: 1px solid var(--border-color); +} + +/* Responsive */ +@media (max-width: 768px) { + .settings-section { + padding: 24px; + } + + .section-header { + flex-direction: column; + gap: 16px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .danger-item { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .danger-item .btn { + width: 100%; + } + + .form-actions { + flex-direction: column; + } + + .form-actions .btn { + width: 100%; + } + + .modal-footer { + flex-direction: column-reverse; + } + + .modal-footer .btn { + width: 100%; + } +} + +@media (max-width: 480px) { + .settings-container { + padding: 0; + } + + .settings-section { + padding: 20px; + margin-bottom: 20px; + } + + .section-info h2 { + font-size: 20px; + } + + .section-icon { + width: 48px; + height: 48px; + } + + .section-icon i { + width: 24px; + height: 24px; + } +} diff --git a/src/css/signin.css b/src/css/signin.css new file mode 100644 index 0000000..9b7dcf2 --- /dev/null +++ b/src/css/signin.css @@ -0,0 +1,1230 @@ +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Variables de Color - Tema Java */ +:root { + --java-orange: #ED8B00; + --java-orange-light: #FFA726; + --java-orange-glow: rgba(237, 139, 0, 0.25); + --java-blue: #007396; + --java-blue-light: #0097C8; + --java-blue-glow: rgba(0, 115, 150, 0.25); + --accent-primary: var(--java-blue); + --accent-primary-light: var(--java-blue-light); + --accent-glow: var(--java-blue-glow); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: #000; + color: #fff; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + animation: pageLoad 0.6s ease-out; +} + +@keyframes pageLoad { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.container { + display: flex; + min-height: 100vh; +} + +/* Left Side - Background Image Section Enhanced */ +.left-side { + flex: 1; + background-image: url(../assets/one.jpg); + background-repeat: no-repeat; + background-size: cover; + background-position: center; + padding: 70px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +/* Overlay mejorado con gradiente más sofisticado */ +.left-side::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(0, 0, 0, 0.7) 0%, + rgba(0, 0, 0, 0.5) 50%, + rgba(0, 0, 0, 0.8) 100%); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} + +/* Efecto de partículas sutiles */ +.left-side::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; +} + +.welcome-content { + position: relative; + z-index: 1; + max-width: 550px; + animation: fadeInUp 1s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Logo mejorado */ +.logo { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 80px; + transition: transform 0.3s ease; +} + +.logo:hover { + transform: translateX(5px); +} + +.logo-icon { + width: 42px; + height: 42px; + border-radius: 12px; + transition: all 0.3s ease; +} + +.logo:hover .logo-icon { + transform: scale(1.05); +} + +.logo span { + font-size: 26px; + font-weight: 800; + background: linear-gradient(135deg, #ffffff 0%, #e0e7ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.8px; + text-shadow: none; +} + +/* Título y descripción mejorados */ +.welcome-content h1 { + font-size: 58px; + font-weight: 900; + margin-bottom: 28px; + line-height: 1.1; + color: white; + letter-spacing: -2px; + background: linear-gradient(135deg, #ffffff 0%, #e0e0e0 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 2px 20px rgba(255, 255, 255, 0.1); +} + +.welcome-content>p { + font-size: 20px; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 70px; + margin-top: 1.5rem; + line-height: 1.8; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + font-weight: 400; +} + +/* Steps mejorados con efectos premium */ +.steps { + display: flex; + flex-direction: column; + gap: 16px; +} + +.step { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 32px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; +} + +.step:hover { + background: rgba(255, 255, 255, 0.12); + transform: translateX(5px); + border-color: rgba(255, 255, 255, 0.2); +} + +.step.active { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%); + color: var(--java-blue); + box-shadow: 0 10px 40px var(--java-blue-glow); + transform: translateX(8px) scale(1.02); + border-color: transparent; +} + +.step-number { + width: 38px; + height: 38px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 17px; + transition: all 0.3s ease; +} + +.step.active .step-number { + background: linear-gradient(135deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + color: white; + box-shadow: 0 4px 12px var(--java-blue-glow); +} + +.step span { + font-size: 16px; + font-weight: 600; + letter-spacing: 0.2px; +} + +/* Right Side - Forms Enhanced */ +.right-side { + flex: 1; + background: linear-gradient(180deg, #0b0b0b 0%, #111111 100%); + padding: 70px; + padding-inline: 5rem; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + position: relative; +} + +.form-container { + width: 100%; + max-width: 500px; + margin: 0 auto; + position: relative; + z-index: 1; + animation: fadeInUp 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.form-container.hidden { + display: none; +} + +.form-container h2 { + font-size: 32px; + font-weight: 800; + margin-bottom: 12px; + color: white; + letter-spacing: -1px; + line-height: 1.2; +} + +.subtitle { + color: #b3b3b3; + margin-bottom: 48px; + font-size: 17px; + line-height: 1.7; + letter-spacing: 0.3px; + font-weight: 400; +} + +/* Social Buttons Enhanced */ +.social-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 32px; +} + +.social-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px 22px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + color: white; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.social-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.social-btn:hover::before { + width: 300px; + height: 300px; +} + +.social-btn:hover { + background: #252525; + border-color: #3a3a3a; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.05); +} + +.social-btn:active { + transform: translateY(0); +} + +.social-btn img { + width: 22px; + height: 22px; + position: relative; + z-index: 1; +} + +.social-btn span { + position: relative; + z-index: 1; +} + +/* Divider Enhanced */ +.divider { + position: relative; + text-align: center; + margin: 36px 0; +} + +.divider::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, #333 50%, transparent 100%); +} + +.divider span { + position: relative; + background: #000; + padding: 0 18px; + color: #666; + font-size: 14px; + font-weight: 500; +} + +/* Forms Enhanced */ +form { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + position: relative; +} + +.form-group label { + font-size: 14px; + font-weight: 600; + color: #e0e0e0; + margin-bottom: 6px; + letter-spacing: 0.4px; + transition: all 0.3s ease; +} + +.form-group:focus-within label { + color: var(--java-blue-light); + transform: translateX(2px); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 18px 22px; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + color: white; + font-size: 15px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + font-weight: 400; + line-height: 1.5; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.form-group input::placeholder { + color: #777; + transition: color 0.3s ease; +} + +/* Estilos para autocompletado - mantiene el tema oscuro */ +.form-group input:-webkit-autofill, +.form-group input:-webkit-autofill:hover, +.form-group input:-webkit-autofill:focus, +.form-group input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 100px rgba(20, 20, 20, 1) inset !important; + -webkit-text-fill-color: rgba(255, 255, 255, 0.85) !important; + border-color: rgba(255, 255, 255, 0.12) !important; + transition: background-color 5000s ease-in-out 0s; + caret-color: rgba(255, 255, 255, 0.9); +} + +.form-group input:hover { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.05); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); +} + +.form-group input:hover::placeholder { + color: #999; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--java-blue); + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 0 0 4px var(--java-blue-glow), + 0 8px 24px rgba(0, 115, 150, 0.3), + 0 0 40px rgba(0, 115, 150, 0.15); + transform: translateY(-2px); +} + +.form-group input:focus::placeholder { + color: #aaa; +} + +.form-group select { + cursor: pointer; +} + +.form-group select option { + background: #1a1a1a; + color: white; +} + +.password-input { + position: relative; +} + +.password-input input { + width: 100%; + padding-right: 55px; +} + +.toggle-password { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + padding: 9px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + width: 38px; + height: 38px; + border-radius: 10px; + z-index: 10; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.toggle-password svg { + width: 20px; + height: 20px; + flex-shrink: 0; + stroke: currentColor; + stroke-width: 2; +} + +.toggle-password:hover { + color: var(--java-blue-light); + background: rgba(0, 115, 150, 0.15); + border-color: var(--java-blue); + transform: translateY(-50%) scale(1.05); +} + +.toggle-password:active { + transform: translateY(-50%) scale(0.98); +} + +/* Forgot Password Link */ +.forgot-password-container { + text-align: right; + margin-top: 10px; + margin-bottom: 1.2rem; +} + +.forgot-password-link { + color: rgba(255, 255, 255, 0.9); + font-size: 15px; + font-weight: 600; + text-decoration: none; + transition: all 0.3s ease; + position: relative; + display: inline-block; +} + +.forgot-password-link::after { + content: ''; + position: absolute; + bottom: -3px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + transition: width 0.3s ease; +} + +.forgot-password-link:hover { + color: var(--java-blue-light); +} + +.forgot-password-link:hover::after { + width: 100%; +} + +.form-group small { + font-size: 13px; + color: #666; + line-height: 1.4; +} + +.error-message { + font-size: 13px; + color: #ef4444; + margin-top: -4px; + font-weight: 500; +} + +/* Enhanced Checkbox Styles */ +.form-check { + display: flex; + align-items: center; + gap: 12px; + margin: 16px 0; + cursor: pointer; + user-select: none; +} + +.form-check input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 20px; + height: 20px; + border: 2px solid #333; + border-radius: 6px; + background: #1a1a1a; + cursor: pointer; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; +} + +.form-check input[type="checkbox"]:hover { + border-color: var(--java-blue); + background: #222; +} + +.form-check input[type="checkbox"]:focus { + outline: none; + box-shadow: 0 0 0 3px var(--java-blue-glow); +} + +/* Checkmark */ +.form-check input[type="checkbox"]::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0) rotate(45deg); + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; +} + +.form-check input[type="checkbox"]:checked { + background: linear-gradient(135deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + border-color: var(--java-blue); +} + +.form-check input[type="checkbox"]:checked::before { + transform: translate(-50%, -55%) scale(1) rotate(45deg); + opacity: 1; +} + +/* Label */ +.form-check label { + font-size: 15px; + color: #d1d5db; + cursor: pointer; + transition: color 0.3s ease; + font-weight: 500; +} + +.form-check:hover label { + color: #fff; +} + +.form-check input[type="checkbox"]:checked+label { + color: #fff; +} + +/* Efecto ripple al hacer click */ +.form-check input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(124, 58, 237, 0.3); + transform: translate(-50%, -50%); + transition: width 0.4s ease, height 0.4s ease, opacity 0.4s ease; + opacity: 0; + pointer-events: none; +} + +.form-check input[type="checkbox"]:active::after { + width: 40px; + height: 40px; + opacity: 1; +} + +/* Variante: Toggle Switch Style (iOS-like) */ +.form-check.toggle-style { + gap: 14px; +} + +.form-check.toggle-style input[type="checkbox"] { + width: 48px; + height: 26px; + border-radius: 13px; + background: #2a2a2a; + border: 2px solid #3a3a3a; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.form-check.toggle-style input[type="checkbox"]::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border: none; + border-radius: 50%; + background: #6b7280; + top: 2px; + left: 2px; + transform: translateX(0); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.form-check.toggle-style input[type="checkbox"]::after { + display: none; /* Desactivar efecto ripple para toggle */ +} + +.form-check.toggle-style input[type="checkbox"]:checked { + background: linear-gradient(135deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + border-color: var(--java-blue); +} + +.form-check.toggle-style input[type="checkbox"]:checked::before { + background: #ffffff; + transform: translateX(22px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.form-check.toggle-style input[type="checkbox"]:hover { + background: #3a3a3a; + border-color: #4a4a4a; +} + +.form-check.toggle-style input[type="checkbox"]:hover::before { + background: #808080; +} + +.form-check.toggle-style input[type="checkbox"]:checked:hover { + background: linear-gradient(135deg, #005d78 0%, var(--java-blue) 100%); + border-color: #005d78; +} + +.form-check.toggle-style input[type="checkbox"]:checked:hover::before { + background: #ffffff; +} + +.form-check.toggle-style input[type="checkbox"]:focus { + box-shadow: 0 0 0 3px var(--java-blue-glow); +} + +/* Submit Button Enhanced - Apple Style */ +.submit-btn { + width: 100%; + padding: 18px 22px; + background: linear-gradient(135deg, #ffffff 0%, #f8f8f8 100%); + color: #000; + border: none; + border-radius: 14px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + margin-top: 0.5rem; + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.12), + 0 1px 3px rgba(255, 255, 255, 0.08); + letter-spacing: 0.5px; + position: relative; + overflow: hidden; +} + +.submit-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(0, 115, 150, 0.08); + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.submit-btn:hover { + background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(255, 255, 255, 0.18), + 0 3px 8px rgba(255, 255, 255, 0.12); +} + +.submit-btn:hover::before { + width: 350px; + height: 350px; +} + +.submit-btn:active { + transform: translateY(0px); + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15); +} + +/* Switch Form Enhanced */ +.switch-form { + text-align: center; + margin-top: 28px; + color: #a3a3a3; + font-size: 15px; + line-height: 1.6; + letter-spacing: 0.2px; +} + +.switch-form a { + color: white; + font-weight: 700; + text-decoration: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.switch-form a::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + transition: width 0.3s ease; +} + +.switch-form a:hover { + color: var(--java-blue-light); +} + +.switch-form a:hover::after { + width: 100%; +} + +/* Loading Enhanced */ +.submit-btn.loading { + pointer-events: none; + opacity: 0.7; + position: relative; +} + +.submit-btn.loading::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + top: 50%; + left: 50%; + margin-left: -9px; + margin-top: -9px; + border: 2px solid #000; + border-radius: 50%; + border-top-color: transparent; + animation: spin 0.6s linear infinite; +} + +/* ================================ + ESTILOS PARA EL MODAL (Añadir al final de signin.css) +================================ */ + +/* --- Capa Oscura (Overlay) --- */ +.modal-overlay { + position: fixed; + /* Fijo en la pantalla */ + inset: 0; + /* Cubre todo (top: 0, left: 0, ...) */ + background: rgba(0, 0, 0, 0.75); + /* Fondo oscuro translúcido */ + display: flex; + /* Usar flex para centrar */ + align-items: center; + justify-content: center; + z-index: 1000; + /* Asegurar que esté encima de todo */ + opacity: 0; + /* Oculto por defecto */ + pointer-events: none; + /* No interactuable cuando está oculto */ + transition: opacity 0.3s ease; +} + +/* --- Contenido del Modal --- */ +.modal-content { + background: var(--bg-card, #1a1a1a); + /* Reutilizar color tarjeta o fallback */ + padding: 40px 35px; + border-radius: 12px; + border: 1px solid var(--border-color, #2a2a2a); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + width: 90%; + /* Ancho base */ + max-width: 450px; + /* Límite en desktop */ + position: relative; + /* Para posicionar el botón de cierre */ + transform: scale(0.95); + /* Efecto de "zoom" al aparecer */ + transition: transform 0.3s ease; +} + +/* --- Estado Visible --- */ +.modal-overlay.visible { + opacity: 1; + pointer-events: auto; + /* Hacer interactuable */ +} + +.modal-overlay.visible .modal-content { + transform: scale(1); +} + +/* --- Estilos Internos --- */ +.modal-content h3 { + font-size: 24px; + font-weight: 700; + margin-bottom: 12px; + color: var(--text-primary, #fff); + text-align: center; +} + +.modal-content .subtitle { + font-size: 15px; + color: var(--text-secondary, #a0a0a0); + margin-bottom: 30px; + text-align: center; + line-height: 1.5; +} + +.modal-content .form-group { + margin-bottom: 25px; + /* Espacio antes del botón */ +} + +/* Reutilizar estilos de input */ +.modal-content input { + width: 100%; + padding: 15px 18px; + background: #101010; + /* Un poco más oscuro que bg-card */ + border: 1px solid #333; + border-radius: 8px; + color: white; + font-size: 15px; +} + +.modal-content input:focus { + outline: none; + border-color: var(--accent-color, #7c3aed); + background: #1f1f1f; +} + +/* Reutilizar estilos de botón */ +.modal-content .submit-btn { + display: block; + /* Ocupar todo el ancho */ + width: 100%; + margin-top: 10px; + padding: 15px 24px; +} + +/* Botón de Cierre (X) */ +.modal-close-btn { + position: absolute; + top: 15px; + right: 15px; + background: none; + border: none; + color: var(--text-secondary, #a0a0a0); + cursor: pointer; + padding: 5px; + line-height: 0; + /* Para alinear bien el icono SVG */ + transition: color 0.3s ease, transform 0.3s ease; +} + +.modal-close-btn:hover { + color: var(--text-primary, #fff); + transform: rotate(90deg); +} + +.modal-close-btn svg { + width: 20px; + height: 20px; +} + +/* Contenedor para alertas DENTRO del modal */ +#modalAlertContainer .message { + margin-bottom: 20px; + /* Espacio antes del botón */ +} + + +/* --- Responsive: Ajustes para Móvil --- */ +@media (max-width: 600px) { + .modal-content { + width: 95%; + /* Un poco más ancho en móvil */ + padding: 30px 25px; + max-height: 90vh; + /* Limitar altura */ + overflow-y: auto; + /* Scroll si el contenido es mucho */ + } + + .modal-content h3 { + font-size: 22px; + } + + .modal-content .subtitle { + font-size: 14px; + margin-bottom: 25px; + } + + .modal-close-btn { + top: 10px; + right: 10px; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Messages Enhanced */ +.message { + padding: 14px 18px; + border-radius: 10px; + margin-bottom: 24px; + font-size: 14px; + display: none; + font-weight: 500; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.show { + display: block; +} + +.message.success { + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.4); + color: #22c55e; +} + +.message.error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +/* ================================ + RESPONSIVE DESIGN ENHANCED +================================ */ + +/* Tablet */ +@media (max-width: 1024px) { + .container { + flex-direction: column; + } + + .left-side { + padding: 50px; + min-height: 450px; + } + + .welcome-content h1 { + font-size: 42px; + } + + .right-side { + padding: 50px; + } + + .form-container h2 { + font-size: 32px; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .left-side { + padding: 40px 30px; + min-height: 350px; + } + + .logo { + margin-bottom: 50px; + } + + .welcome-content h1 { + font-size: 38px; + margin-bottom: 20px; + } + + .welcome-content>p { + font-size: 17px; + margin-bottom: 50px; + } + + .step { + padding: 18px 24px; + } + + .step-number { + width: 34px; + height: 34px; + font-size: 15px; + } + + .step span { + font-size: 15px; + } + + .right-side { + padding: 40px 28px; + padding-inline: 2rem; + } + + .form-container { + max-width: 100%; + } + + .form-container h2 { + font-size: 28px; + } + + .subtitle { + font-size: 15px; + margin-bottom: 30px; + } + + .social-buttons { + grid-template-columns: 1fr; + gap: 12px; + } + + .social-btn { + padding: 14px 20px; + } +} + +/* Extra Small Mobile */ +@media (max-width: 480px) { + .left-side { + padding: 30px 20px; + min-height: 300px; + } + + .logo { + margin-bottom: 40px; + } + + .logo-icon { + width: 32px; + height: 32px; + } + + .logo span { + font-size: 19px; + } + + .welcome-content h1 { + font-size: 28px; + letter-spacing: -1px; + } + + .welcome-content>p { + font-size: 15px; + margin-bottom: 40px; + } + + .steps { + gap: 12px; + } + + .step { + padding: 16px 20px; + gap: 16px; + } + + .right-side { + padding: 30px 20px; + } + + .form-container h2 { + font-size: 26px; + } + + .form-group input, + .form-group select { + padding: 14px 18px; + font-size: 15px; + } + + .submit-btn { + padding: 15px 24px; + font-size: 15px; + } +} + +/* Desktop Large */ +@media (min-width: 1440px) { + .left-side { + padding: 80px; + } + + .welcome-content { + max-width: 600px; + } + + .welcome-content h1 { + font-size: 56px; + } + + .welcome-content>p { + font-size: 20px; + } + + .right-side { + padding: 80px; + } + + .form-container { + max-width: 520px; + } + + .form-container h2 { + font-size: 40px; + } +} \ No newline at end of file diff --git a/src/css/signup.css b/src/css/signup.css new file mode 100644 index 0000000..285107f --- /dev/null +++ b/src/css/signup.css @@ -0,0 +1,872 @@ +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Variables de Color - Tema Java */ +:root { + --java-orange: #ED8B00; + --java-orange-light: #FFA726; + --java-orange-glow: rgba(237, 139, 0, 0.25); + --java-blue: #007396; + --java-blue-light: #0097C8; + --java-blue-glow: rgba(0, 115, 150, 0.25); + --accent-primary: var(--java-blue); + --accent-primary-light: var(--java-blue-light); + --accent-glow: var(--java-blue-glow); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: #000; + color: #fff; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + animation: pageLoad 0.6s ease-out; +} + +@keyframes pageLoad { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.container { + display: flex; + min-height: 100vh; +} + +/* Left Side - Background Image Section Enhanced */ +.left-side { + flex: 1; + background-image: url(../assets/one.jpg); + background-repeat: no-repeat; + background-size: cover; + background-position: center; + padding: 70px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +/* Overlay mejorado con gradiente sofisticado */ +.left-side::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(0, 0, 0, 0.65) 0%, + rgba(0, 0, 0, 0.45) 50%, + rgba(0, 0, 0, 0.75) 100%); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} + +/* Efecto de partículas sutiles - Removido para evitar cuadro morado */ +.left-side::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; +} + +.welcome-content { + position: relative; + z-index: 1; + max-width: 550px; + animation: fadeInUp 1s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Logo mejorado */ +.logo { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 80px; + transition: transform 0.3s ease; +} + +.logo:hover { + transform: translateX(5px); +} + +.logo span { + font-size: 26px; + font-weight: 800; + background: linear-gradient(135deg, #ffffff 0%, #e0e7ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.8px; + text-shadow: none; +} + +/* Título y descripción mejorados */ +.welcome-content h1 { + font-size: 58px; + font-weight: 900; + margin-bottom: 28px; + line-height: 1.1; + color: white; + letter-spacing: -2px; + background: linear-gradient(135deg, #ffffff 0%, #e0e0e0 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 2px 20px rgba(255, 255, 255, 0.1); +} + +.welcome-content> p { + font-size: 20px; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 70px; + margin-top: 1.5rem; + line-height: 1.8; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + font-weight: 400; +} + +/* Steps mejorados con efectos premium */ +.steps { + display: flex; + flex-direction: column; + gap: 16px; +} + +.step { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 32px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; +} + +.step:hover { + background: rgba(255, 255, 255, 0.12); + transform: translateX(5px); + border-color: rgba(255, 255, 255, 0.2); +} + +.step.active { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%); + color: var(--java-blue); + box-shadow: 0 10px 40px var(--java-blue-glow); + transform: translateX(8px) scale(1.02); + border-color: transparent; +} + +.step-number { + width: 38px; + height: 38px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 17px; + transition: all 0.3s ease; +} + +.step.active .step-number { + background: linear-gradient(135deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + color: white; + box-shadow: 0 4px 12px var(--java-blue-glow); +} + +.step span { + font-size: 16px; + font-weight: 600; + letter-spacing: 0.2px; +} + +/* Right Side - Forms Enhanced */ +.right-side { + flex: 1; + background: linear-gradient(180deg, #0b0b0b 0%, #111111 100%); + padding: 70px; + padding-inline: 5rem; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + position: relative; +} + +.form-container { + width: 100%; + max-width: 520px; + position: relative; + z-index: 1; + animation: fadeInUp 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.form-container.hidden { + display: none; +} + +.form-container h2 { + font-size: 32px; + font-weight: 800; + margin-bottom: 12px; + color: white; + letter-spacing: -1px; + line-height: 1.2; +} + +.subtitle { + color: #b3b3b3; + margin-bottom: 48px; + font-size: 17px; + line-height: 1.7; + letter-spacing: 0.3px; + font-weight: 400; +} + +/* Social Buttons Enhanced */ +.social-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 32px; +} + +.social-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px 22px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + color: white; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.social-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.social-btn:hover::before { + width: 300px; + height: 300px; +} + +.social-btn:hover { + background: #252525; + border-color: #3a3a3a; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.05); +} + +.social-btn:active { + transform: translateY(0); +} + +.social-btn img { + width: 22px; + height: 22px; + position: relative; + z-index: 1; +} + +.social-btn span { + position: relative; + z-index: 1; +} + +/* Divider Enhanced */ +.divider { + position: relative; + text-align: center; + margin: 36px 0; +} + +.divider::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, #333 50%, transparent 100%); +} + +.divider span { + position: relative; + background: #000; + padding: 0 18px; + color: #666; + font-size: 14px; + font-weight: 500; +} + +/* Forms Enhanced */ +form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 12px; + position: relative; +} + +.form-group label { + font-size: 14px; + font-weight: 600; + color: #e0e0e0; + margin-bottom: 6px; + letter-spacing: 0.4px; + transition: all 0.3s ease; +} + +.form-group:focus-within label { + color: var(--java-blue-light); + transform: translateX(2px); +} + +.form-group input, +.form-group select { + padding: 18px 22px; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + color: white; + font-size: 15px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + font-weight: 400; + line-height: 1.5; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.form-group input::placeholder { + color: #777; + transition: color 0.3s ease; +} + +/* Estilos para autocompletado - mantiene el tema oscuro */ +.form-group input:-webkit-autofill, +.form-group input:-webkit-autofill:hover, +.form-group input:-webkit-autofill:focus, +.form-group input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 100px rgba(20, 20, 20, 1) inset !important; + -webkit-text-fill-color: rgba(255, 255, 255, 0.85) !important; + border-color: rgba(255, 255, 255, 0.12) !important; + transition: background-color 5000s ease-in-out 0s; + caret-color: rgba(255, 255, 255, 0.9); +} + +.form-group input:hover { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.05); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); +} + +.form-group input:hover::placeholder { + color: #999; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--java-blue); + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 0 0 4px var(--java-blue-glow), + 0 8px 24px rgba(0, 115, 150, 0.3), + 0 0 40px rgba(0, 115, 150, 0.15); + transform: translateY(-2px); +} + +.form-group input:focus::placeholder { + color: #aaa; +} + +.form-group select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 20px center; + padding-right: 50px; +} + +.form-group select option { + background: #1a1a1a; + color: white; +} + +.password-input { + position: relative; +} + +.password-input input { + width: 100%; + padding-right: 55px; +} + +.toggle-password { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + padding: 9px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + width: 38px; + height: 38px; + border-radius: 10px; + z-index: 10; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.toggle-password svg { + width: 20px; + height: 20px; + flex-shrink: 0; + stroke: currentColor; + stroke-width: 2; +} + +.toggle-password:hover { + color: var(--java-blue-light); + background: rgba(0, 115, 150, 0.15); + border-color: var(--java-blue); + transform: translateY(-50%) scale(1.05); +} + +.toggle-password:active { + transform: translateY(-50%) scale(0.98); +} + +.form-group small { + font-size: 13px; + color: #666; + line-height: 1.4; +} + +.error-message { + font-size: 13px; + color: #ef4444; + margin-top: -4px; + font-weight: 500; +} + +/* Submit Button Enhanced - Apple Style */ +.submit-btn { + width: 100%; + padding: 18px 22px; + background: linear-gradient(135deg, #ffffff 0%, #f8f8f8 100%); + color: #000; + border: none; + border-radius: 14px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + margin-top: 0.5rem; + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.12), + 0 1px 3px rgba(255, 255, 255, 0.08); + letter-spacing: 0.5px; + position: relative; + overflow: hidden; +} + +.submit-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(237, 139, 0, 0.08); + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.submit-btn:hover { + background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(255, 255, 255, 0.18), + 0 3px 8px rgba(255, 255, 255, 0.12); +} + +.submit-btn:hover::before { + width: 350px; + height: 350px; +} + +.submit-btn:active { + transform: translateY(0px); + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15); +} + +/* Switch Form Enhanced */ +.switch-form { + text-align: center; + margin-top: 28px; + color: #a3a3a3; + font-size: 15px; + line-height: 1.6; + letter-spacing: 0.2px; +} + +.switch-form a { + color: white; + font-weight: 700; + text-decoration: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.switch-form a::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, var(--java-blue) 0%, var(--java-blue-light) 100%); + transition: width 0.3s ease; +} + +.switch-form a:hover { + color: var(--java-blue-light); +} + +.switch-form a:hover::after { + width: 100%; +} + +/* Loading Enhanced */ +.submit-btn.loading { + pointer-events: none; + opacity: 0.7; + position: relative; +} + +.submit-btn.loading::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + top: 50%; + left: 50%; + margin-left: -9px; + margin-top: -9px; + border: 2px solid #000; + border-radius: 50%; + border-top-color: transparent; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Messages Enhanced */ +.message { + padding: 14px 18px; + border-radius: 10px; + margin-bottom: 24px; + font-size: 14px; + display: none; + font-weight: 500; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.show { + display: block; +} + +.message.success { + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.4); + color: #22c55e; +} + +.message.error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +/* ================================ + RESPONSIVE DESIGN ENHANCED +================================ */ + +/* Tablet */ +@media (max-width: 1024px) { + .container { + flex-direction: column; + } + + .left-side { + padding: 50px; + min-height: 450px; + } + + .welcome-content h1 { + font-size: 42px; + } + + .right-side { + padding: 50px; + } + + .form-container { + max-width: 550px; + } + + .form-container h2 { + font-size: 32px; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .left-side { + padding: 40px 30px; + min-height: 350px; + } + + .logo { + margin-bottom: 50px; + } + + .welcome-content h1 { + font-size: 38px; + margin-bottom: 20px; + } + + .welcome-content> p { + font-size: 17px; + margin-bottom: 50px; + } + + .step { + padding: 18px 24px; + } + + .step-number { + width: 34px; + height: 34px; + font-size: 15px; + } + + .step span { + font-size: 15px; + } + + .right-side { + padding: 40px 28px; + padding-inline: 2rem; + } + + .form-container { + max-width: 100%; + } + + .form-container h2 { + font-size: 28px; + } + + .subtitle { + font-size: 15px; + margin-bottom: 30px; + } + + .social-buttons { + grid-template-columns: 1fr; + gap: 12px; + } + + .social-btn { + padding: 14px 20px; + } + + .form-row { + grid-template-columns: 1fr; + gap: 0; + } + + .form-group input, + .form-group select { + padding: 15px 18px; + } +} + +/* Extra Small Mobile */ +@media (max-width: 480px) { + .left-side { + padding: 30px 20px; + min-height: 300px; + } + + .logo { + margin-bottom: 40px; + } + + .logo span { + font-size: 19px; + } + + .welcome-content h1 { + font-size: 28px; + letter-spacing: -1px; + } + + .welcome-content> p { + font-size: 15px; + margin-bottom: 40px; + } + + .steps { + gap: 12px; + } + + .step { + padding: 16px 20px; + gap: 16px; + } + + .right-side { + padding: 30px 20px; + } + + .form-container h2 { + font-size: 26px; + } + + .form-group input, + .form-group select { + padding: 14px 18px; + font-size: 15px; + } + + .submit-btn { + padding: 15px 24px; + font-size: 15px; + } +} + +/* Desktop Large */ +@media (min-width: 1440px) { + .left-side { + padding: 80px; + } + + .welcome-content { + max-width: 600px; + } + + .welcome-content h1 { + font-size: 56px; + } + + .welcome-content> p { + font-size: 20px; + } + + .right-side { + padding: 80px; + } + + .form-container { + max-width: 560px; + } + + .form-container h2 { + font-size: 40px; + } + + .form-row { + gap: 18px; + } +} \ No newline at end of file diff --git a/src/css/verify-email.css b/src/css/verify-email.css new file mode 100644 index 0000000..b06dabf --- /dev/null +++ b/src/css/verify-email.css @@ -0,0 +1,1054 @@ +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: #000; + color: #fff; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.container { + display: flex; + min-height: 100vh; +} + +/* Left Side - Background Image Section Enhanced */ +.left-side { + flex: 1; + background-image: url(../assets/one.jpg); + background-repeat: no-repeat; + background-size: cover; + background-position: center; + padding: 70px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +/* Overlay mejorado con gradiente más sofisticado */ +.left-side::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} + +/* Efecto de partículas sutiles */ +.left-side::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; +} + +.welcome-content { + position: relative; + z-index: 1; + max-width: 550px; + animation: fadeInUp 1s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Logo mejorado */ +.logo { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 70px; + transition: transform 0.3s ease; +} + +.logo:hover { + transform: translateX(5px); +} + +.logo-icon { + width: 38px; + height: 38px; + border-radius: 10px; + transition: all 0.3s ease; +} + +.logo:hover .logo-icon { + transform: scale(1.05); +} + +.logo span { + font-size: 22px; + font-weight: 700; + color: white; + letter-spacing: -0.5px; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); +} + +/* Título y descripción mejorados */ +.welcome-content h1 { + font-size: 52px; + font-weight: 800; + margin-bottom: 24px; + line-height: 1.15; + color: white; + letter-spacing: -1.5px; + background: linear-gradient(135deg, #ffffff 0%, #e0e0e0 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 2px 20px rgba(255, 255, 255, 0.1); +} + +.welcome-content>p { + font-size: 19px; + color: rgba(255, 255, 255, 0.92); + margin-bottom: 70px; + line-height: 1.7; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + font-weight: 400; +} + +/* Steps mejorados con efectos premium */ +.steps { + display: flex; + flex-direction: column; + gap: 16px; +} + +.step { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 32px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; +} + +.step:hover { + background: rgba(255, 255, 255, 0.12); + transform: translateX(5px); + border-color: rgba(255, 255, 255, 0.2); +} + +.step.active { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%); + color: #6b21a8; + box-shadow: 0 10px 40px rgba(124, 58, 237, 0.3); + transform: translateX(8px) scale(1.02); + border-color: transparent; +} + +.step-number { + width: 38px; + height: 38px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 17px; + transition: all 0.3s ease; +} + +.step.active .step-number { + background: linear-gradient(135deg, #7c3aed 0%, #6b21a8 100%); + color: white; + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); +} + +.step span { + font-size: 16px; + font-weight: 600; + letter-spacing: 0.2px; +} + +/* Right Side - Forms Enhanced */ +.right-side { + flex: 1; + background: #000; + padding: 70px; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + position: relative; +} + +/* Gradiente sutil de fondo */ +.right-side::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 50%; + height: 100%; + background: radial-gradient(circle at top right, rgba(124, 58, 237, 0.03) 0%, transparent 70%); + pointer-events: none; +} + +.form-container { + width: 100%; + max-width: 480px; + margin: 0 auto; + position: relative; + z-index: 1; + animation: fadeIn 0.6s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.form-container.hidden { + display: none; +} + +.form-container h2 { + font-size: 36px; + font-weight: 800; + margin-bottom: 14px; + color: white; + letter-spacing: -1px; +} + +.subtitle { + color: #999; + margin-bottom: 36px; + font-size: 16px; + line-height: 1.5; +} + +/* Social Buttons Enhanced */ +.social-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 32px; +} + +.social-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px 22px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + color: white; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.social-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.social-btn:hover::before { + width: 300px; + height: 300px; +} + +.social-btn:hover { + background: #252525; + border-color: #3a3a3a; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.05); +} + +.social-btn:active { + transform: translateY(0); +} + +.social-btn img { + width: 22px; + height: 22px; + position: relative; + z-index: 1; +} + +.social-btn span { + position: relative; + z-index: 1; +} + +/* Divider Enhanced */ +.divider { + position: relative; + text-align: center; + margin: 36px 0; +} + +.divider::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, #333 50%, transparent 100%); +} + +.divider span { + position: relative; + background: #000; + padding: 0 18px; + color: #666; + font-size: 14px; + font-weight: 500; +} + +/* Forms Enhanced */ +form { + display: flex; + flex-direction: column; + gap: 26px; + width: 100%; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.form-group label { + font-size: 14px; + font-weight: 600; + color: #d1d5db; + margin-bottom: 4px; + letter-spacing: 0.2px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 16px 20px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + color: white; + font-size: 15px; + transition: all 0.3s ease; + font-weight: 400; +} + +.form-group input::placeholder { + color: #666; +} + +/* Estilos para autocompletado - mantiene el tema oscuro */ +.form-group input:-webkit-autofill, +.form-group input:-webkit-autofill:hover, +.form-group input:-webkit-autofill:focus, +.form-group input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 100px rgba(20, 20, 20, 1) inset !important; + -webkit-text-fill-color: rgba(255, 255, 255, 0.85) !important; + border-color: rgba(255, 255, 255, 0.12) !important; + transition: background-color 5000s ease-in-out 0s; + caret-color: rgba(255, 255, 255, 0.9); +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #7c3aed; + background: #222; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); +} + +.form-group select { + cursor: pointer; +} + +.form-group select option { + background: #1a1a1a; + color: white; +} + +.password-input { + position: relative; +} + +.password-input input { + width: 100%; + padding-right: 55px; +} + +.toggle-password { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + border-radius: 6px; +} + +.toggle-password:hover { + color: #999; + background: rgba(255, 255, 255, 0.05); +} + +.form-group small { + font-size: 13px; + color: #666; + line-height: 1.4; +} + +.error-message { + font-size: 13px; + color: #ef4444; + margin-top: -4px; + font-weight: 500; +} + +/* Enhanced Checkbox Styles */ +.form-check { + display: flex; + align-items: center; + gap: 12px; + margin: 16px 0; + cursor: pointer; + user-select: none; +} + +.form-check input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 20px; + height: 20px; + border: 2px solid #333; + border-radius: 6px; + background: #1a1a1a; + cursor: pointer; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; +} + +.form-check input[type="checkbox"]:hover { + border-color: #7c3aed; + background: #222; +} + +.form-check input[type="checkbox"]:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2); +} + +/* Checkmark */ +.form-check input[type="checkbox"]::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0) rotate(45deg); + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; +} + +.form-check input[type="checkbox"]:checked { + background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%); + border-color: #7c3aed; +} + +.form-check input[type="checkbox"]:checked::before { + transform: translate(-50%, -55%) scale(1) rotate(45deg); + opacity: 1; +} + +/* Label */ +.form-check label { + font-size: 15px; + color: #d1d5db; + cursor: pointer; + transition: color 0.3s ease; + font-weight: 500; +} + +.form-check:hover label { + color: #fff; +} + +.form-check input[type="checkbox"]:checked+label { + color: #fff; +} + +/* Efecto ripple al hacer click */ +.form-check input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(124, 58, 237, 0.3); + transform: translate(-50%, -50%); + transition: width 0.4s ease, height 0.4s ease, opacity 0.4s ease; + opacity: 0; + pointer-events: none; +} + +.form-check input[type="checkbox"]:active::after { + width: 40px; + height: 40px; + opacity: 1; +} + +/* Variante: Checkbox con toggle switch style (opcional) */ +.form-check.toggle-style { + gap: 14px; +} + +.form-check.toggle-style input[type="checkbox"] { + width: 44px; + height: 24px; + border-radius: 12px; + background: #333; + border: none; +} + +.form-check.toggle-style input[type="checkbox"]::before { + width: 18px; + height: 18px; + border: none; + border-radius: 50%; + background: #666; + top: 3px; + left: 3px; + transform: translateX(0); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.form-check.toggle-style input[type="checkbox"]:checked { + background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%); +} + +.form-check.toggle-style input[type="checkbox"]:checked::before { + background: white; + transform: translateX(20px); +} + +.form-check.toggle-style input[type="checkbox"]:hover { + background: #444; +} + +.form-check.toggle-style input[type="checkbox"]:checked:hover { + background: linear-gradient(135deg, #6b21a8 0%, #7c3aed 100%); +} + +/* Submit Button Enhanced */ +.submit-btn { + padding: 16px 28px; + background: linear-gradient(135deg, #ffffff 0%, #f3f4f6 100%); + color: #000; + border: none; + border-radius: 10px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + margin-top: 12px; + box-shadow: 0 4px 15px rgba(255, 255, 255, 0.1); + letter-spacing: 0.3px; +} + +.submit-btn:hover { + background: linear-gradient(135deg, #f3f4f6 0%, #ffffff 100%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 255, 255, 0.2); +} + +.submit-btn:active { + transform: translateY(0); +} + +/* Switch Form Enhanced */ +.switch-form { + text-align: center; + margin-top: 24px; + color: #999; + font-size: 15px; +} + +.switch-form a { + color: white; + font-weight: 700; + text-decoration: none; + transition: all 0.3s ease; + position: relative; +} + +.switch-form a::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, #7c3aed 0%, #a78bfa 100%); + transition: width 0.3s ease; +} + +.switch-form a:hover { + color: #a855f7; +} + +.switch-form a:hover::after { + width: 100%; +} + +/* Loading Enhanced */ +.submit-btn.loading { + pointer-events: none; + opacity: 0.7; + position: relative; +} + +.submit-btn.loading::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + top: 50%; + left: 50%; + margin-left: -9px; + margin-top: -9px; + border: 2px solid #000; + border-radius: 50%; + border-top-color: transparent; + animation: spin 0.6s linear infinite; +} + +/* src/css/verify-email.css */ + +/* Hereda estilos de signin.css, aquí solo añadimos/modificamos */ + +.verification-box { + text-align: center; +} + +.icon-container { + margin-bottom: 25px; + color: var(--accent-color); + /* Usa tu color de acento */ +} + +.verification-box h2 { + margin-bottom: 15px; +} + +.verification-box .subtitle { + margin-bottom: 30px; + line-height: 1.6; + color: var(--text-secondary); + /* Un gris más claro */ +} + +.verification-box strong { + color: var(--text-primary); + /* Resaltar el email */ + font-weight: 600; +} + +.actions { + margin-top: 30px; + margin-bottom: 25px; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.actions .submit-btn { + width: 100%; + max-width: 250px; + /* Limitar ancho del botón */ + text-decoration: none; + /* Asegurar que no haya subrayado */ + display: inline-block; + /* Para que padding funcione bien */ + text-align: center; +} + +.logout-link { + color: var(--text-secondary); + font-size: 14px; + text-decoration: none; + transition: color 0.3s ease; +} + +.logout-link:hover { + color: var(--accent-light); + text-decoration: underline; +} + +.info-text { + font-size: 13px; + color: var(--text-tertiary); + /* El gris más oscuro */ + margin-top: 20px; +} + +#resendEmailBtn { + /* Quitamos el fondo y borde por defecto */ + background: none; + border: 1px solid transparent; + /* Borde invisible */ + + /* Usamos un color de texto menos llamativo (gris secundario) */ + color: var(--text-secondary); + + font-weight: 500; + /* Un poco menos grueso que el botón principal */ + box-shadow: none; + /* Quitar sombra si la tuviera */ + + /* Mantenemos el tamaño y centrado del .actions .submit-btn */ + width: 100%; + max-width: 250px; + text-decoration: none; + display: inline-block; + text-align: center; + padding: 14px 24px; + /* Ajustar padding si es necesario */ + margin-top: 0; + /* Quitar margen superior si .actions ya tiene gap */ +} + +/* Efecto Hover: Hacemos que se parezca más al secundario */ +#resendEmailBtn:hover { + background: rgba(124, 58, 237, 0.05); + /* Fondo muy sutil */ + color: var(--accent-light); + /* Color de acento claro */ + border-color: rgba(124, 58, 237, 0.3); + /* Borde sutil */ + transform: none; + /* Quitar efecto de elevación si no te gusta */ + box-shadow: none; +} + +/* Estilo cuando está deshabilitado (mientras envía) */ +#resendEmailBtn:disabled { + color: var(--text-tertiary); + /* Color gris más oscuro */ + cursor: not-allowed; + background: none; + border-color: transparent; +} + +/* Spinner (opcional, si quieres feedback visual al verificar) */ +.spinner { + margin: 20px auto 0; + border: 4px solid rgba(255, 255, 255, 0.1); + border-left-color: var(--accent-color); + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Estilos adicionales para el botón secundario si no los tienes ya */ +.btn-secondary { + background: transparent; + color: var(--accent-color); + border: 1px solid var(--accent-color); +} + +.btn-secondary:hover { + background: rgba(124, 58, 237, 0.1); + color: var(--accent-light); + border-color: var(--accent-light); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Messages Enhanced */ +.message { + padding: 14px 18px; + border-radius: 10px; + margin-bottom: 24px; + font-size: 14px; + display: none; + font-weight: 500; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.show { + display: block; +} + +.message.success { + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.4); + color: #22c55e; +} + +.message.error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +/* ================================ + RESPONSIVE DESIGN ENHANCED +================================ */ + +/* Tablet */ +@media (max-width: 1024px) { + .container { + flex-direction: column; + } + + .left-side { + padding: 50px; + min-height: 450px; + } + + .welcome-content h1 { + font-size: 42px; + } + + .right-side { + padding: 50px; + } + + .form-container h2 { + font-size: 32px; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .left-side { + padding: 40px 30px; + min-height: 350px; + } + + .logo { + margin-bottom: 50px; + } + + .welcome-content h1 { + font-size: 34px; + margin-bottom: 20px; + } + + .welcome-content>p { + font-size: 17px; + margin-bottom: 50px; + } + + .step { + padding: 18px 24px; + } + + .step-number { + width: 34px; + height: 34px; + font-size: 15px; + } + + .step span { + font-size: 15px; + } + + .right-side { + padding: 40px 24px; + } + + .form-container { + max-width: 100%; + } + + .form-container h2 { + font-size: 28px; + } + + .subtitle { + font-size: 15px; + margin-bottom: 30px; + } + + .social-buttons { + grid-template-columns: 1fr; + gap: 12px; + } + + .social-btn { + padding: 14px 20px; + } +} + +/* Extra Small Mobile */ +@media (max-width: 480px) { + .left-side { + padding: 30px 20px; + min-height: 300px; + } + + .logo { + margin-bottom: 40px; + } + + .logo-icon { + width: 32px; + height: 32px; + } + + .logo span { + font-size: 19px; + } + + .welcome-content h1 { + font-size: 28px; + letter-spacing: -1px; + } + + .welcome-content>p { + font-size: 15px; + margin-bottom: 40px; + } + + .steps { + gap: 12px; + } + + .step { + padding: 16px 20px; + gap: 16px; + } + + .right-side { + padding: 30px 20px; + } + + .form-container h2 { + font-size: 26px; + } + + .form-group input, + .form-group select { + padding: 14px 18px; + font-size: 15px; + } + + .submit-btn { + padding: 15px 24px; + font-size: 15px; + } +} + +/* Desktop Large */ +@media (min-width: 1440px) { + .left-side { + padding: 80px; + } + + .welcome-content { + max-width: 600px; + } + + .welcome-content h1 { + font-size: 56px; + } + + .welcome-content>p { + font-size: 20px; + } + + .right-side { + padding: 80px; + } + + .form-container { + max-width: 520px; + } + + .form-container h2 { + font-size: 40px; + } +} \ No newline at end of file diff --git a/src/js/admin-config.js b/src/js/admin-config.js new file mode 100644 index 0000000..2de7cdf --- /dev/null +++ b/src/js/admin-config.js @@ -0,0 +1,60 @@ +/** + * admin-config.js + * Configuración centralizada de administradores + * + * Para agregar un nuevo administrador, simplemente añade su email a la lista ADMIN_EMAILS + */ + +/** + * Lista de emails autorizados como administradores + * Cuando un usuario se registra con uno de estos emails, + * automáticamente se le asigna el rol de administrador + * + * IMPORTANTE: Los emails deben estar en minúsculas + */ +export const ADMIN_EMAILS = [ + 'fcuadros@itsoeh.edu.mx', + 'deepdevjose@itsoeh.edu.mx', + '230110197@itsoeh.edu.mx', + '230110073@itsoeh.edu.mx', + '230110449@itsoeh.edu.mx', + '230110874@itsoeh.edu.mx', + '230110443@itsoeh.edu.mx', + '230110050@itsoeh.edu.mx', + '230110166@itsoeh.edu.mx', + '230110084@itsoeh.edu.mx', + '230110063@itsoeh.edu.mx', + '230110077@itsoeh.edu.mx', + 'galy7977@gmail.com', + '230110689@itsoeh.edu.mx', + '230110313@itsoeh.edu.mx', + '230110530@itsoeh.edu.mx', + '230110581@itsoeh.edu.mx', + '230110579@itsoeh.edu.mx', + 'joseecodm@gmail.com' + // Agrega más emails aquí según sea necesario + // 'profesor@itsoeh.edu.mx', + // 'admin@itsoeh.edu.mx', +]; + +/** + * Verifica si un email está en la lista de administradores + * @param {string} email - Email a verificar + * @returns {boolean} True si es email de admin + */ +export function isAdminEmail(email) { + if (!email) return false; + return ADMIN_EMAILS.includes(email.toLowerCase().trim()); +} + +/** + * Permisos por defecto para administradores + */ +export const DEFAULT_ADMIN_PERMISSIONS = { + createExercises: true, + editExercises: true, + deleteExercises: true, + viewAllSubmissions: true, + manageUsers: true, + viewAnalytics: true +}; diff --git a/src/js/admin.js b/src/js/admin.js new file mode 100644 index 0000000..3366a50 --- /dev/null +++ b/src/js/admin.js @@ -0,0 +1,1032 @@ +/** + * admin.js - Panel de Administración + * Gestión completa de ejercicios con tests dinámicos + */ + +import { auth, db } from './firebase-init.js'; +import { onAuthStateChanged } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js"; +import { + collection, + doc, + getDoc, + getDocs, + addDoc, + setDoc, + updateDoc, + deleteDoc, + query, + where, + orderBy, + limit, + serverTimestamp +} from "https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js"; +import { tryUpdateStats, incrementStat, initializeStats } from './stats-updater.js'; + +// ========================================== +// GLOBAL STATE +// ========================================== +let currentUser = null; +let isAdmin = false; +let currentExerciseId = null; +let allExercises = []; +let filteredExercises = []; + +// Cache configuration +const ADMIN_EXERCISES_CACHE_KEY = 'admin_exercises_cache_v1'; +const ADMIN_EXERCISES_CACHE_TTL = 2 * 60 * 1000; // 2 minutos (más corto para admins) + +// ========================================== +// DOM ELEMENTS +// ========================================== +const elements = { + // Sidebar + sidebar: document.getElementById('sidebar'), + sidebarOverlay: document.getElementById('sidebarOverlay'), + adminName: document.getElementById('adminName'), + adminAvatar: document.querySelector('.admin-avatar'), + + // Navigation + navItems: document.querySelectorAll('.nav-item[data-section]'), + contentSections: document.querySelectorAll('.content-section'), + pageTitle: document.getElementById('pageTitle'), + pageSubtitle: document.getElementById('pageSubtitle'), + + // Exercises + exercisesGrid: document.getElementById('exercisesGrid'), + createExerciseBtn: document.getElementById('createExerciseBtn'), + + // Filters and Search + adminSearchInput: document.getElementById('adminSearchInput'), + authorFilter: document.getElementById('authorFilter'), + adminGridViewBtn: document.getElementById('adminGridViewBtn'), + adminListViewBtn: document.getElementById('adminListViewBtn'), + + // Modal + exerciseModal: document.getElementById('exerciseModal'), + closeExerciseModal: document.getElementById('closeExerciseModal'), + exerciseForm: document.getElementById('exerciseForm'), + modalTitle: document.getElementById('modalTitle'), + + // Form fields + exerciseTitle: document.getElementById('exerciseTitle'), + exerciseCategory: document.getElementById('exerciseCategory'), + exerciseDifficulty: document.getElementById('exerciseDifficulty'), + exercisePoints: document.getElementById('exercisePoints'), + exerciseDescription: document.getElementById('exerciseDescription'), + exerciseAuthor: document.getElementById('exerciseAuthor'), + exerciseTheoryLink: document.getElementById('exerciseTheoryLink'), + exerciseTemplate: document.getElementById('exerciseTemplate'), + exerciseTestCode: document.getElementById('exerciseTestCode'), + exerciseSolutionCode: document.getElementById('exerciseSolutionCode'), + cancelExerciseBtn: document.getElementById('cancelExerciseBtn'), + saveExerciseBtn: document.getElementById('saveExerciseBtn'), + + // Toast + toastContainer: document.getElementById('toastContainer') +}; + +// ========================================== +// INITIALIZATION +// ========================================== +document.addEventListener('DOMContentLoaded', () => { + console.log('🚀 Admin Panel inicializando...'); + + // Setup auth listener + onAuthStateChanged(auth, async (user) => { + if (user) { + currentUser = user; + await checkAdminAccess(user); + } else { + // Redirect to signin if not authenticated + window.location.href = 'signin.html'; + } + }); + + setupEventListeners(); +}); + +// ========================================== +// AUTH & PERMISSIONS +// ========================================== +async function checkAdminAccess(user) { + console.log('🔐 Verificando acceso de administrador...'); + + try { + // Check if user is in admins collection + const adminDoc = await getDoc(doc(db, 'admins', user.email)); + + if (adminDoc.exists()) { + isAdmin = true; + console.log('✅ Usuario es administrador'); + + // Load user data from usuarios collection + try { + const userDoc = await getDoc(doc(db, 'usuarios', user.uid)); + if (userDoc.exists()) { + const userData = userDoc.data(); + const githubUsername = userData.githubUsername || user.email.split('@')[0]; + const displayName = userData.firstName && userData.lastName + ? `${userData.firstName} ${userData.lastName}` + : githubUsername; + + // Update admin name + if (elements.adminName) { + elements.adminName.textContent = displayName; + } + + // Update admin avatar + if (elements.adminAvatar && githubUsername) { + elements.adminAvatar.src = `https://github.com/${githubUsername}.png`; + elements.adminAvatar.alt = displayName; + } + } else { + // Fallback to email if user doc doesn't exist + if (elements.adminName) { + elements.adminName.textContent = user.email.split('@')[0]; + } + if (elements.adminAvatar) { + elements.adminAvatar.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(user.email.split('@')[0])}&background=3b82f6&color=fff`; + } + } + } catch (error) { + console.error('❌ Error al cargar datos del usuario:', error); + // Fallback to email + if (elements.adminName) { + elements.adminName.textContent = user.email.split('@')[0]; + } + } + + // Initialize admin panel + initializeAdminPanel(); + } else { + // Not an admin, redirect to dashboard + console.log('❌ Usuario no es administrador, redirigiendo...'); + showToast('error', 'Acceso Denegado', 'No tienes permisos de administrador'); + setTimeout(() => { + window.location.href = 'dashboard.html'; + }, 2000); + } + } catch (error) { + console.error('❌ Error al verificar permisos:', error); + showToast('error', 'Error', 'No se pudo verificar los permisos'); + } +} + +function initializeAdminPanel() { + console.log('✅ Inicializando panel de administración'); + loadExercises(); + + // Inicializar stats si no existe (solo admins pueden) + initializeStats().catch(err => console.warn('⚠️ Stats init:', err)); + + // Intentar actualizar stats agregados en segundo plano + tryUpdateStats().catch(err => console.warn('⚠️ Stats update:', err)); + + loadStats(); +} + +// ========================================== +// EVENT LISTENERS +// ========================================== +function setupEventListeners() { + // Navigation + elements.navItems.forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const section = item.dataset.section; + switchSection(section); + }); + }); + + // Create exercise button + if (elements.createExerciseBtn) { + elements.createExerciseBtn.addEventListener('click', () => { + openExerciseModal(); + }); + } + + // Close modal + if (elements.closeExerciseModal) { + elements.closeExerciseModal.addEventListener('click', closeExerciseModal); + } + + // Cancel button + if (elements.cancelExerciseBtn) { + elements.cancelExerciseBtn.addEventListener('click', closeExerciseModal); + } + + // Form submission + if (elements.exerciseForm) { + elements.exerciseForm.addEventListener('submit', handleExerciseSubmit); + } + + // Close modal on outside click + if (elements.exerciseModal) { + elements.exerciseModal.addEventListener('click', (e) => { + if (e.target === elements.exerciseModal) { + closeExerciseModal(); + } + }); + } + + // Search input + if (elements.adminSearchInput) { + elements.adminSearchInput.addEventListener('input', (e) => { + applyFilters(); + }); + } + + // Author filter + if (elements.authorFilter) { + elements.authorFilter.addEventListener('change', () => { + applyFilters(); + }); + } + + // View toggle buttons + if (elements.adminGridViewBtn) { + elements.adminGridViewBtn.addEventListener('click', () => { + setAdminView('grid'); + }); + } + + if (elements.adminListViewBtn) { + elements.adminListViewBtn.addEventListener('click', () => { + setAdminView('list'); + }); + } + + // Load saved view preference + const savedView = localStorage.getItem('adminExercisesView') || 'grid'; + setAdminView(savedView); + + // Restore sidebar state + const sidebarCollapsed = localStorage.getItem('adminSidebarCollapsed') === 'true'; + if (sidebarCollapsed && elements.sidebar) { + elements.sidebar.classList.add('collapsed'); + } +} + +// ========================================== +// NAVIGATION +// ========================================== +function switchSection(sectionName) { + // Update nav items + elements.navItems.forEach(item => { + if (item.dataset.section === sectionName) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); + + // Update content sections + elements.contentSections.forEach(section => { + if (section.id === `${sectionName}-section`) { + section.classList.add('active'); + } else { + section.classList.remove('active'); + } + }); + + // Update header + const titles = { + exercises: { + title: 'Gestión de Ejercicios', + subtitle: 'Crea y administra ejercicios de Java' + }, + users: { + title: 'Gestión de Usuarios', + subtitle: 'Administra los usuarios del sistema' + }, + analytics: { + title: 'Analíticas del Sistema', + subtitle: 'Estadísticas y métricas generales' + } + }; + + const sectionData = titles[sectionName]; + if (sectionData) { + elements.pageTitle.textContent = sectionData.title; + elements.pageSubtitle.textContent = sectionData.subtitle; + } + + // Load section data + if (sectionName === 'users') { + // loadUsers(); // DESHABILITADO: Sección de usuarios removida + } else if (sectionName === 'analytics') { + loadStats(); + } +} + +// ========================================== +// LOAD EXERCISES +// ========================================== +async function loadExercises() { + console.log('📚 Cargando ejercicios...'); + + try { + // Intentar cargar del caché (solo para lectura rápida) + try { + const cached = localStorage.getItem(ADMIN_EXERCISES_CACHE_KEY); + if (cached) { + const { data, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < ADMIN_EXERCISES_CACHE_TTL) { + console.log('📦 Cargando ejercicios desde caché'); + allExercises = data; + populateAuthorFilter(); + filteredExercises = [...allExercises]; + renderExercises(filteredExercises); + return; + } + } + } catch (cacheError) { + console.warn('⚠️ Error al leer caché:', cacheError); + } + + console.log('🔄 Cargando ejercicios desde Firestore'); + const exercisesSnapshot = await getDocs(collection(db, 'exercises')); + allExercises = []; + + exercisesSnapshot.forEach(doc => { + allExercises.push({ id: doc.id, ...doc.data() }); + }); + + console.log(`✅ ${allExercises.length} ejercicios cargados`); + + // Guardar en caché + try { + localStorage.setItem(ADMIN_EXERCISES_CACHE_KEY, JSON.stringify({ + data: allExercises, + timestamp: Date.now() + })); + } catch (cacheError) { + console.warn('⚠️ Error al guardar caché:', cacheError); + } + + // Populate author filter + populateAuthorFilter(); + + // Initial render + filteredExercises = [...allExercises]; + renderExercises(filteredExercises); + + } catch (error) { + console.error('❌ Error al cargar ejercicios:', error); + showToast('error', 'Error', 'No se pudieron cargar los ejercicios'); + } +} + +function renderExercises(exercises) { + if (!elements.exercisesGrid) return; + + if (exercises.length === 0) { + elements.exercisesGrid.innerHTML = ` +
+ +

No hay ejercicios creados

+

Haz clic en "Nuevo Ejercicio" para crear uno

+
+ `; + feather.replace(); + return; + } + + elements.exercisesGrid.innerHTML = exercises.map(exercise => ` +
+
+
+

${exercise.title || 'Sin título'}

+
+ ${getDifficultyLabel(exercise.difficulty)} + ${exercise.category || 'General'} +
+
+
+ +

${exercise.description || 'Sin descripción'}

+ + ${exercise.author ? `
+ + Por ${exercise.author} +
` : ''} + +
+ ${exercise.points || 0} puntos + ${exercise.tests?.length || 0} tests + ${exercise.theoryLink ? ` + Teoría + ` : ''} +
+ +
+ + +
+
+ `).join(''); + + feather.replace(); +} + +function getDifficultyLabel(difficulty) { + const labels = { + easy: 'Fácil', + medium: 'Medio', + hard: 'Difícil' + }; + return labels[difficulty] || difficulty; +} + +// ========================================== +// FILTERING AND SEARCH +// ========================================== +function populateAuthorFilter() { + if (!elements.authorFilter) return; + + // Get unique authors + const authors = [...new Set(allExercises + .map(ex => ex.author) + .filter(author => author) + )].sort(); + + // Clear and populate filter + elements.authorFilter.innerHTML = ''; + authors.forEach(author => { + const option = document.createElement('option'); + option.value = author; + option.textContent = author; + elements.authorFilter.appendChild(option); + }); +} + +function applyFilters() { + const searchTerm = elements.adminSearchInput?.value.toLowerCase().trim() || ''; + const selectedAuthor = elements.authorFilter?.value || 'all'; + + filteredExercises = allExercises.filter(exercise => { + // Search filter + const matchesSearch = !searchTerm || + (exercise.title?.toLowerCase().includes(searchTerm)) || + (exercise.author?.toLowerCase().includes(searchTerm)) || + (exercise.category?.toLowerCase().includes(searchTerm)) || + (exercise.description?.toLowerCase().includes(searchTerm)); + + // Author filter + const matchesAuthor = selectedAuthor === 'all' || exercise.author === selectedAuthor; + + return matchesSearch && matchesAuthor; + }); + + renderExercises(filteredExercises); +} + +function setAdminView(viewType) { + if (!elements.exercisesGrid) return; + + // Update container class + if (viewType === 'list') { + elements.exercisesGrid.classList.add('list-view'); + } else { + elements.exercisesGrid.classList.remove('list-view'); + } + + // Update button states + if (elements.adminGridViewBtn && elements.adminListViewBtn) { + if (viewType === 'list') { + elements.adminGridViewBtn.classList.remove('active'); + elements.adminListViewBtn.classList.add('active'); + } else { + elements.adminGridViewBtn.classList.add('active'); + elements.adminListViewBtn.classList.remove('active'); + } + } + + // Save preference + localStorage.setItem('adminExercisesView', viewType); + + // Re-render to apply layout + feather.replace(); +} + +// ========================================== +// MODAL MANAGEMENT +// ========================================== +function openExerciseModal(exerciseId = null) { + currentExerciseId = exerciseId; + + if (exerciseId) { + // Edit mode + elements.modalTitle.textContent = 'Editar Ejercicio'; + loadExerciseData(exerciseId); + } else { + // Create mode + elements.modalTitle.textContent = 'Crear Nuevo Ejercicio'; + resetForm(); + } + + elements.exerciseModal.classList.add('active'); + document.body.style.overflow = 'hidden'; + + // Replace feather icons in modal + setTimeout(() => feather.replace(), 100); +} + +function closeExerciseModal() { + elements.exerciseModal.classList.remove('active'); + document.body.style.overflow = ''; + resetForm(); +} + +function resetForm() { + elements.exerciseForm.reset(); + currentExerciseId = null; + + // Restaurar botón de guardar al estado original + if (elements.saveExerciseBtn) { + elements.saveExerciseBtn.disabled = false; + elements.saveExerciseBtn.innerHTML = ' Guardar Ejercicio'; + feather.replace(); + } +} + +async function loadExerciseData(exerciseId) { + try { + const exerciseDoc = await getDoc(doc(db, 'exercises', exerciseId)); + + if (!exerciseDoc.exists()) { + showToast('error', 'Error', 'Ejercicio no encontrado'); + closeExerciseModal(); + return; + } + + const exercise = exerciseDoc.data(); + + // Fill form + elements.exerciseTitle.value = exercise.title || ''; + elements.exerciseCategory.value = exercise.category || ''; + elements.exerciseDifficulty.value = exercise.difficulty || ''; + elements.exercisePoints.value = exercise.points || 0; + elements.exerciseDescription.value = exercise.description || ''; + elements.exerciseAuthor.value = exercise.author || ''; + elements.exerciseTheoryLink.value = exercise.theoryLink || ''; + elements.exerciseTemplate.value = exercise.templateCode || ''; + elements.exerciseTestCode.value = exercise.testCode || ''; + elements.exerciseSolutionCode.value = exercise.solutionCode || ''; + + } catch (error) { + console.error('❌ Error al cargar ejercicio:', error); + showToast('error', 'Error', 'No se pudo cargar el ejercicio'); + } +} + +// ========================================== +// FORM SUBMISSION +// ========================================== +async function handleExerciseSubmit(e) { + e.preventDefault(); + + if (!isAdmin) { + showToast('error', 'Acceso Denegado', 'No tienes permisos para realizar esta acción'); + return; + } + + try { + // Disable button + elements.saveExerciseBtn.disabled = true; + elements.saveExerciseBtn.innerHTML = ' Guardando...'; + feather.replace(); + + // Collect form data + const exerciseData = { + title: elements.exerciseTitle.value.trim(), + category: elements.exerciseCategory.value, + difficulty: elements.exerciseDifficulty.value, + points: parseInt(elements.exercisePoints.value) || 0, + description: elements.exerciseDescription.value.trim(), + author: elements.exerciseAuthor.value.trim(), + theoryLink: elements.exerciseTheoryLink.value.trim() || null, + templateCode: elements.exerciseTemplate.value.trim(), + testCode: elements.exerciseTestCode.value.trim(), + solutionCode: elements.exerciseSolutionCode.value.trim(), + updatedAt: serverTimestamp(), + updatedBy: currentUser.email + }; + + // Validation + if (!exerciseData.testCode || exerciseData.testCode.length === 0) { + showToast('error', 'Validación', 'Debes agregar el código del test'); + elements.saveExerciseBtn.disabled = false; + elements.saveExerciseBtn.innerHTML = ' Guardar Ejercicio'; + feather.replace(); + return; + } + + if (!exerciseData.solutionCode || exerciseData.solutionCode.length === 0) { + showToast('error', 'Validación', 'Debes agregar el código de solución'); + elements.saveExerciseBtn.disabled = false; + elements.saveExerciseBtn.innerHTML = ' Guardar Ejercicio'; + feather.replace(); + return; + } + + if (currentExerciseId) { + // Update existing exercise + await updateDoc(doc(db, 'exercises', currentExerciseId), exerciseData); + console.log('✅ Ejercicio actualizado:', currentExerciseId); + showToast('success', 'Éxito', 'Ejercicio actualizado correctamente'); + } else { + // Create new exercise + exerciseData.createdAt = serverTimestamp(); + exerciseData.createdBy = currentUser.email; + + const docRef = await addDoc(collection(db, 'exercises'), exerciseData); + console.log('✅ Ejercicio creado:', docRef.id); + + // Incrementar contador de ejercicios en stats + incrementStat('totalExercises').catch(err => console.warn('⚠️ Stat update:', err)); + + showToast('success', 'Éxito', 'Ejercicio creado correctamente'); + } + + // Reload exercises and close modal + await loadExercises(); + + // Invalidar caché + localStorage.removeItem(ADMIN_EXERCISES_CACHE_KEY); + + closeExerciseModal(); + + } catch (error) { + console.error('❌ Error al guardar ejercicio:', error); + showToast('error', 'Error', 'No se pudo guardar el ejercicio: ' + error.message); + + // Re-enable button + elements.saveExerciseBtn.disabled = false; + elements.saveExerciseBtn.innerHTML = ' Guardar Ejercicio'; + feather.replace(); + } +} + +// ========================================== +// EDIT/DELETE EXERCISE +// ========================================== +window.editExercise = function(exerciseId) { + openExerciseModal(exerciseId); +}; + +window.deleteExercise = async function(exerciseId) { + if (!isAdmin) { + showToast('error', 'Acceso Denegado', 'No tienes permisos para realizar esta acción'); + return; + } + + const confirmed = confirm('¿Estás seguro de que deseas eliminar este ejercicio? Esta acción no se puede deshacer.'); + + if (!confirmed) return; + + try { + await deleteDoc(doc(db, 'exercises', exerciseId)); + console.log('✅ Ejercicio eliminado:', exerciseId); + + // Decrementar contador de ejercicios en stats + incrementStat('totalExercises', -1).catch(err => console.warn('⚠️ Stat update:', err)); + + // Invalidar caché + localStorage.removeItem(ADMIN_EXERCISES_CACHE_KEY); + + showToast('success', 'Éxito', 'Ejercicio eliminado correctamente'); + loadExercises(); + } catch (error) { + console.error('❌ Error al eliminar ejercicio:', error); + showToast('error', 'Error', 'No se pudo eliminar el ejercicio'); + } +}; + +// ========================================== +// LOAD STATS +// ========================================== +let statsCache = null; +let statsCacheTime = 0; +const STATS_CACHE_TTL = 2 * 60 * 1000; // 2 minutos + +async function loadStats() { + try { + const now = Date.now(); + + // Usar caché si tiene menos de 2 minutos + if (statsCache && (now - statsCacheTime) < STATS_CACHE_TTL) { + updateStatsUI(statsCache); + return; + } + + // Intentar cargar documento de stats agregados + try { + const statsDoc = await getDoc(doc(db, 'stats', 'general')); + + if (statsDoc.exists()) { + const stats = statsDoc.data(); + statsCache = stats; + statsCacheTime = now; + updateStatsUI(stats); + return; + } + } catch (error) { + console.warn('⚠️ No se encontró documento de stats, calculando...'); + } + + // Fallback: calcular manualmente (costoso) + console.log('📊 Calculando estadísticas...'); + showToast('info', 'Calculando...', 'Esto puede tardar un momento'); + + // Usar Promise.all para cargar en paralelo + const [usersSnapshot, exercisesSnapshot, submissionsSnapshot, resultsSnapshot] = await Promise.all([ + getDocs(collection(db, 'usuarios')), + getDocs(collection(db, 'exercises')), + getDocs(collection(db, 'submissions')), + getDocs(collection(db, 'results')) + ]); + + let successCount = 0; + resultsSnapshot.forEach(doc => { + if (doc.data().status === 'success') { + successCount++; + } + }); + + const stats = { + totalUsers: usersSnapshot.size, + totalExercises: exercisesSnapshot.size, + totalSubmissions: submissionsSnapshot.size, + successCount: successCount, + totalResults: resultsSnapshot.size, + successRate: resultsSnapshot.size> 0 ? Math.round((successCount / resultsSnapshot.size) * 100) : 0 + }; + + statsCache = stats; + statsCacheTime = now; + updateStatsUI(stats); + + } catch (error) { + console.error('❌ Error al cargar estadísticas:', error); + } +} + +function updateStatsUI(stats) { + document.getElementById('totalUsers').textContent = stats.totalUsers || 0; + document.getElementById('totalExercises').textContent = stats.totalExercises || 0; + document.getElementById('totalSubmissions').textContent = stats.totalSubmissions || 0; + document.getElementById('successRate').textContent = (stats.successRate || 0) + '%'; +} + +// ========================================== +// LOAD USERS - DESHABILITADO (Sección removida del admin panel) +// ========================================== +/* +let currentUsersPage = 0; +const USERS_PER_PAGE = 20; + +async function loadUsers(page = 0) { + try { + // Usar limit para paginación + const usersQuery = query( + collection(db, 'usuarios'), + orderBy('createdAt', 'desc'), + limit(USERS_PER_PAGE) + ); + + const usersSnapshot = await getDocs(usersQuery); + const users = []; + + usersSnapshot.forEach(doc => { + users.push({ id: doc.id, ...doc.data() }); + }); + + currentUsersPage = page; + renderUsers(users, page); + } catch (error) { + console.error('❌ Error al cargar usuarios:', error); + showToast('error', 'Error', 'No se pudieron cargar los usuarios'); + } +} +*/ + +/* +function renderUsers(users) { + const tbody = document.getElementById('usersTableBody'); + if (!tbody) return; + + if (users.length === 0) { + tbody.innerHTML = '
No hay usuarios registrados
'; + return; + } + + tbody.innerHTML = users.map(user => ` +
+ ${user.firstName || ''} ${user.lastName || ''} + ${user.email || 'N/A'} + ${user.matricula || 'N/A'} + ${user.githubUsername || 'N/A'} + 0 + + + + +
+ `).join(''); + + feather.replace(); +} +*/ + +// ========================================== +// VIEW USER DETAILS - DESHABILITADO +// ========================================== +/* +window.viewUserDetails = async function(userId) { + console.log('👁️ Ver detalles de usuario:', userId); + showToast('info', 'Información', 'Funcionalidad en desarrollo'); +}; +*/ + +// ========================================== +// DELETE USER - DESHABILITADO +// ========================================== +/* +window.deleteUser = async function(userId, userEmail) { + if (!isAdmin) { + showToast('error', 'Acceso Denegado', 'No tienes permisos para realizar esta acción'); + return; + } + + try { + // Confirmación estricta + const confirmText = prompt( + `⚠️ ADVERTENCIA CRÍTICA: ELIMINACIÓN PERMANENTE DE USUARIO\n\n` + + `Estás a punto de ELIMINAR PERMANENTEMENTE al usuario:\n` + + `📧 Email: ${userEmail}\n\n` + + `Esta acción eliminará TODA la información del usuario:\n` + + `✓ Documento de usuario (usuarios)\n` + + `✓ Todos sus envíos (submissions)\n` + + `✓ Todos sus resultados (results)\n` + + `✓ Todos sus borradores de código (code_drafts)\n` + + `✓ Mapeo de GitHub username (github_usernames)\n` + + `✓ Mapeo de matrícula (matriculas)\n\n` + + `⚠️ ESTA ACCIÓN NO SE PUEDE DESHACER ⚠️\n\n` + + `Para confirmar, escribe exactamente: ELIMINAR USUARIO` + ); + + if (confirmText !== "ELIMINAR USUARIO") { + showToast('info', 'Cancelado', 'Eliminación cancelada'); + return; + } + + showToast('info', 'Eliminando', 'Eliminando usuario y todos sus datos...'); + + // Obtener datos del usuario + const userDoc = await getDoc(doc(db, 'usuarios', userId)); + if (!userDoc.exists()) { + throw new Error('Usuario no encontrado'); + } + + const userData = userDoc.data(); + let deletedItems = { + submissions: 0, + results: 0, + code_drafts: 0, + github_username: 0, + matricula: 0 + }; + + // 1. Eliminar todos los code_drafts del usuario + console.log('🗑️ Eliminando code_drafts...'); + const draftsQuery = query( + collection(db, 'code_drafts'), + where('userId', '==', userId) + ); + const draftsSnapshot = await getDocs(draftsQuery); + const draftDeletes = draftsSnapshot.docs.map(d => deleteDoc(d.ref)); + await Promise.all(draftDeletes); + deletedItems.code_drafts = draftsSnapshot.size; + console.log(`✅ ${draftsSnapshot.size} code_drafts eliminados`); + + // 2. Eliminar todos los submissions del usuario + console.log('🗑️ Eliminando submissions...'); + const submissionsQuery = query( + collection(db, 'submissions'), + where('userId', '==', userId) + ); + const submissionsSnapshot = await getDocs(submissionsQuery); + const submissionDeletes = submissionsSnapshot.docs.map(d => deleteDoc(d.ref)); + await Promise.all(submissionDeletes); + deletedItems.submissions = submissionsSnapshot.size; + console.log(`✅ ${submissionsSnapshot.size} submissions eliminados`); + + // 3. Eliminar todos los results del usuario + console.log('🗑️ Eliminando results...'); + const resultsQuery = query( + collection(db, 'results'), + where('userId', '==', userId) + ); + const resultsSnapshot = await getDocs(resultsQuery); + const resultDeletes = resultsSnapshot.docs.map(d => deleteDoc(d.ref)); + await Promise.all(resultDeletes); + deletedItems.results = resultsSnapshot.size; + console.log(`✅ ${resultsSnapshot.size} results eliminados`); + + // 4. Eliminar mapeo de GitHub username + if (userData.githubUsername) { + console.log('🗑️ Eliminando mapeo de GitHub username...'); + try { + const githubDocRef = doc(db, 'github_usernames', userData.githubUsername); + await deleteDoc(githubDocRef); + deletedItems.github_username = 1; + console.log(`✅ GitHub username mapping eliminado: ${userData.githubUsername}`); + } catch (error) { + console.warn('⚠️ No se pudo eliminar mapeo de GitHub:', error); + } + } + + // 5. Eliminar mapeo de matrícula + if (userData.matricula) { + console.log('🗑️ Eliminando mapeo de matrícula...'); + try { + const matriculaDocRef = doc(db, 'matriculas', userData.matricula); + await deleteDoc(matriculaDocRef); + deletedItems.matricula = 1; + console.log(`✅ Matrícula mapping eliminada: ${userData.matricula}`); + } catch (error) { + console.warn('⚠️ No se pudo eliminar mapeo de matrícula:', error); + } + } + + // 6. Eliminar documento de usuario + console.log('🗑️ Eliminando documento de usuario...'); + await deleteDoc(doc(db, 'usuarios', userId)); + console.log(`✅ Documento de usuario eliminado`); + + // Mostrar resumen + const summary = + `Usuario ${userEmail} eliminado correctamente.\n\n` + + `Elementos eliminados:\n` + + `📄 Usuario: 1\n` + + `📝 Submissions: ${deletedItems.submissions}\n` + + `📊 Results: ${deletedItems.results}\n` + + `💾 Code drafts: ${deletedItems.code_drafts}\n` + + `🔗 GitHub mapping: ${deletedItems.github_username}\n` + + `🎓 Matrícula mapping: ${deletedItems.matricula}\n\n` + + `⚠️ IMPORTANTE: El usuario debe ser eliminado manualmente de Firebase Authentication.`; + + console.log('✅ USUARIO ELIMINADO COMPLETAMENTE'); + console.log(summary); + + showToast('success', 'Usuario Eliminado', summary); + + // Recargar lista de usuarios + // loadUsers(); // DESHABILITADO + + } catch (error) { + console.error('❌ Error al eliminar usuario:', error); + showToast('error', 'Error', `No se pudo eliminar el usuario: ${error.message}`); + } +}; +*/ + +// ========================================== +// TOAST NOTIFICATIONS +// ========================================== +function showToast(type, title, message) { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + const icons = { + success: 'check-circle', + error: 'x-circle', + info: 'info' + }; + + toast.innerHTML = ` + +
+
${title}
+
${message}
+
+ `; + + elements.toastContainer.appendChild(toast); + feather.replace(); + + // Auto remove after 5 seconds + setTimeout(() => { + toast.style.animation = 'toastSlideIn 0.3s ease reverse'; + setTimeout(() => toast.remove(), 300); + }, 5000); +} diff --git a/src/js/dashboard.js b/src/js/dashboard.js new file mode 100644 index 0000000..cca40fd --- /dev/null +++ b/src/js/dashboard.js @@ -0,0 +1,1420 @@ +// Importar módulos de Firebase Auth y Firestore +import { auth, db } from './firebase-init.js'; +import { onAuthStateChanged, signOut } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js"; +import { doc, getDoc, onSnapshot, collection, query, where, getDocs } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js"; + +/** + * @file dashboard.js + * Lógica principal para la página del dashboard. + * Integrado con Firebase Auth y Firestore, incluye cierre de sesión por inactividad y verificación de correo. + */ + +// --- UTILIDADES DE LOGGING (Entorno-aware) --- +/** + * Detecta si la aplicación corre en entorno de desarrollo. + * @returns {boolean} True si está en localhost o 127.0.0.1 + */ +const isDevelopment = () => { + return window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'; +}; + +/** + * Log de depuración (solo en desarrollo). + * @param {...any} args - Argumentos a loguear + */ +const logDebug = (...args) => { + if (isDevelopment()) console.log(...args); +}; + +/** + * Log de información (solo en desarrollo). + * @param {...any} args - Argumentos a loguear + */ +const logInfo = (...args) => { + if (isDevelopment()) console.info(...args); +}; + +/** + * Log de advertencia (solo en desarrollo). + * @param {...any} args - Argumentos a loguear + */ +const logWarn = (...args) => { + if (isDevelopment()) console.warn(...args); +}; + +/** + * Log de error (solo en desarrollo, sin exponer detalles en producción). + * @param {...any} args - Argumentos a loguear + */ +const logError = (...args) => { + if (isDevelopment()) console.error(...args); +}; + +// --- ESTADO GLOBAL Y CLEANUP --- +let unsubscribeSnapshot = null; +let unsubscribeAuth = null; +let unsubscribeStats = null; // Para estadísticas de ejercicios +let unsubscribeSubmissions = null; // Para envíos recientes +let inactivityListeners = []; +let avatarCache = new Map(); // Cache para avatares de GitHub + +// Cache de ejercicios para evitar lecturas repetidas +let exercisesCache = null; +let exercisesCacheTime = 0; +const EXERCISES_CACHE_TTL = 5 * 60 * 1000; // 5 minutos + +// Cache de nombres de ejercicios en localStorage +function getExerciseNameFromCache(exerciseId) { + try { + const cached = localStorage.getItem(`exercise_name_${exerciseId}`); + if (cached) { + const { name, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < 3600000) { // 1 hora + return name; + } + } + } catch (e) { + logError('Error al leer caché:', e); + } + return null; +} + +function setExerciseNameCache(exerciseId, name) { + try { + localStorage.setItem(`exercise_name_${exerciseId}`, JSON.stringify({ + name, + timestamp: Date.now() + })); + } catch (e) { + logError('Error al guardar caché:', e); + } +} + +// Función para obtener ejercicios con caché +async function getExercisesWithCache() { + const now = Date.now(); + if (exercisesCache && (now - exercisesCacheTime) < EXERCISES_CACHE_TTL) { + logDebug('📦 Usando caché de ejercicios'); + return exercisesCache; + } + + logDebug('🔄 Recargando ejercicios desde Firestore'); + const snapshot = await getDocs(collection(db, 'exercises')); + exercisesCache = snapshot; + exercisesCacheTime = now; + return snapshot; +} + +document.addEventListener('DOMContentLoaded', () => { + + // --- LÓGICA DE AUTENTICACIÓN Y CARGA DE DATOS --- + unsubscribeAuth = onAuthStateChanged(auth, (user) => { + if (user) { + // --- USUARIO AUTENTICADO --- + logDebug('✅ Usuario autenticado'); + + // --- COMPROBACIÓN DE CORREO VERIFICADO --- + if (user.emailVerified) { + // SI está verificado -> Cargar Dashboard + logDebug('✅ Correo verificado. Cargando dashboard...'); + loadDashboardData(user.uid); + startInactivityMonitoring(); + } else { + // NO está verificado -> Redirigir a página de verificación + logWarn('⚠️ Correo NO verificado. Redirigiendo a verificación...'); + cleanupBeforeNavigation(); + window.location.href = 'verify-email.html'; + } + + } else { + // --- USUARIO NO AUTENTICADO --- + logDebug('❌ Usuario no autenticado. Redirigiendo a signin...'); + cleanupBeforeNavigation(); + window.location.href = 'signin.html'; + } + }); + + // --- LÓGICA DEL MENÚ LATERAL --- + setupSidebar(); + + // --- CONTROL MULTI-TAB: Detectar logout en otra pestaña --- + window.addEventListener('storage', handleStorageChange); +}); + + +/** + * Carga los datos del dashboard desde Firestore en tiempo real. + * + * @param {string} uid - El ID de usuario único de Firebase Auth. + * @returns {void} + */ +function loadDashboardData(uid) { + const userDocRef = doc(db, 'usuarios', uid); + + // Mostrar skeleton loaders + showSkeletonLoaders(); + + unsubscribeSnapshot = onSnapshot(userDocRef, (doc) => { + if (doc.exists()) { + const userData = doc.data(); + logDebug("✅ Datos del usuario cargados desde Firestore"); + + // Actualizar UI con datos del perfil + loadUserData(userData); + updateLastCommit(userData); + + // Verificar si es administrador y mostrar enlace al panel admin + checkAdminAccess(uid); + + // Cargar estadísticas de ejercicios desde la colección results + loadExerciseStatistics(uid); + + // Cargar envíos recientes + loadRecentSubmissions(uid); + + // Mostrar toast de bienvenida (solo la primera vez) + if (!sessionStorage.getItem('welcomeShown')) { + setTimeout(() => { + showToast('success', '¡Bienvenido!', `Hola ${userData.firstName || 'Usuario'}, tus datos están actualizados.`); + sessionStorage.setItem('welcomeShown', 'true'); + }, 500); + } + + } else { + hideSkeletonLoaders(); + logError("❌ Error: No se encontró el documento del usuario en Firestore"); + showUserFriendlyError("No se pudo cargar tu perfil. Por favor, intenta recargar la página."); + } + }, (error) => { + hideSkeletonLoaders(); + logError("❌ Error al obtener datos de Firestore:", error.code); + showUserFriendlyError("Error de conexión. Verifica tu internet y recarga la página."); + }); +} + + +/** + * Verifica si el usuario actual es administrador y muestra el enlace al panel admin + * + * @param {string} uid - UID del usuario autenticado + * @returns {void} + */ +async function checkAdminAccess(uid) { + try { + // Obtener el email del usuario desde Auth + const user = auth.currentUser; + if (!user || !user.email) return; + + // Verificar si existe en la colección de admins + const adminDoc = await getDoc(doc(db, 'admins', user.email)); + + if (adminDoc.exists()) { + // Es administrador, mostrar el enlace + const adminMenuItem = document.getElementById('adminMenuItem'); + if (adminMenuItem) { + adminMenuItem.style.display = 'block'; + logDebug('✅ Usuario es administrador, mostrando enlace al panel admin'); + } + } + } catch (error) { + logDebug('⚠️ Error al verificar acceso de admin:', error.code); + // No hacer nada, simplemente no mostrar el enlace + } +} + + +/** + * Carga y monitorea las estadísticas de ejercicios desde la colección results. + * Calcula: ejercicios completados, puntos totales, tests pasados, progreso del curso. + * + * @param {string} uid - UID del usuario autenticado. + * @returns {void} + */ +async function loadExerciseStatistics(uid) { + logInfo("📈 Cargando estadísticas de ejercicios para UID:", uid); + + try { + // Desuscribir listener anterior si existe + if (unsubscribeStats) { + unsubscribeStats(); + unsubscribeStats = null; + } + + // Listener en tiempo real para results del usuario + const resultsQuery = query( + collection(db, 'results'), + where('userId', '==', uid) + ); + + unsubscribeStats = onSnapshot(resultsQuery, async (resultsSnapshot) => { + logDebug(`✅ ${resultsSnapshot.size} resultados encontrados`); + + // Agrupar resultados por exerciseId y quedarnos solo con el más reciente de cada uno + const latestResultsByExercise = new Map(); + + resultsSnapshot.forEach(doc => { + const result = doc.data(); + const exerciseId = result.exerciseId; + const completedAt = result.completedAt?.toDate() || new Date(0); + + // Si no existe o este es más reciente, lo guardamos + if (!latestResultsByExercise.has(exerciseId)) { + latestResultsByExercise.set(exerciseId, { ...result, docId: doc.id, completedAt }); + } else { + const existing = latestResultsByExercise.get(exerciseId); + if (completedAt> existing.completedAt) { + latestResultsByExercise.set(exerciseId, { ...result, docId: doc.id, completedAt }); + } + } + }); + + logDebug(`🎯 Ejercicios únicos encontrados: ${latestResultsByExercise.size}`); + + // Calcular estadísticas solo con los últimos intentos + const completedExerciseIds = new Set(); + let totalTestsPassed = 0; + let totalTestsFailed = 0; + + latestResultsByExercise.forEach((result, exerciseId) => { + // Extraer valores con manejo robusto + const testsPassed = parseInt(result.testsPassed) || 0; + const testsFailed = parseInt(result.testsFailed) || 0; + const testsRun = parseInt(result.testsRun) || 0; + + // Si testsRun existe pero testsPassed/testsFailed no, calcular + let finalTestsPassed = testsPassed; + let finalTestsFailed = testsFailed; + + if (testsRun> 0 && (testsPassed === 0 && testsFailed === 0)) { + // Si status es success, todos pasaron + if (result.status === 'success') { + finalTestsPassed = testsRun; + finalTestsFailed = 0; + } else { + // Si hay error pero no sabemos cuántos fallaron, marcar todos como fallidos + finalTestsPassed = 0; + finalTestsFailed = testsRun; + } + } + + logDebug(`📊 Ejercicio ${exerciseId}:`, { + status: result.status, + testsPassed: result.testsPassed, + testsFailed: result.testsFailed, + testsRun: result.testsRun, + finalTestsPassed, + finalTestsFailed + }); + + if (result.status === 'success') { + completedExerciseIds.add(exerciseId); + } + + totalTestsPassed += finalTestsPassed; + totalTestsFailed += finalTestsFailed; + }); + + const completedCount = completedExerciseIds.size; + logDebug(`✅ Ejercicios completados exitosamente: ${completedCount}`); + + // Obtener puntos de los ejercicios completados usando caché + let totalPoints = 0; + let totalExercises = 0; + + if (completedCount> 0) { + const exercisesSnapshot = await getExercisesWithCache(); + totalExercises = exercisesSnapshot.size; + + exercisesSnapshot.forEach(doc => { + const exercise = doc.data(); + if (completedExerciseIds.has(doc.id)) { + // Intentar leer el campo points con varios formatos posibles + let points = exercise.points || exercise[' points'] || exercise['"points"'] || 0; + + // Convertir a número si es string + if (typeof points === 'string') { + points = parseInt(points.replace(/['"]/g, ''), 10) || 0; + } + + logDebug(`💎 Ejercicio ${doc.id}: ${points} puntos`); + totalPoints += points; + } + }); + } else { + // Si no hay ejercicios completados, obtener solo el total + const exercisesSnapshot = await getExercisesWithCache(); + totalExercises = exercisesSnapshot.size; + } + + logDebug(`💰 Puntos totales calculados: ${totalPoints}`); + + // Calcular estadísticas + const testsTotal = totalTestsPassed + totalTestsFailed; + const successRate = testsTotal> 0 ? Math.round((totalTestsPassed / testsTotal) * 100) : 0; + const courseProgress = totalExercises> 0 ? Math.round((completedCount / totalExercises) * 100) : 0; + + const stats = { + testsPassed: totalTestsPassed, + testsFailed: totalTestsFailed, + testsTotal: testsTotal, + successRate: successRate, + courseProgress: courseProgress, + completedExercises: completedCount, + totalExercises: totalExercises, + totalPoints: totalPoints + }; + + logDebug("📊 Estadísticas calculadas:", stats); + + // Actualizar UI + hideSkeletonLoaders(); + animateStats(stats); + + }, (error) => { + logError("❌ Error al cargar estadísticas:", error); + hideSkeletonLoaders(); + // Mostrar stats vacías si hay error + animateStats({ + testsPassed: 0, + testsFailed: 0, + testsTotal: 0, + successRate: 0, + courseProgress: 0, + completedExercises: 0, + totalExercises: 0 + }); + }); + + } catch (error) { + logError("❌ Error al configurar listener de estadísticas:", error); + hideSkeletonLoaders(); + } +} + + +/** + * Carga y muestra los envíos recientes del usuario + * + * @param {string} uid - UID del usuario autenticado + * @returns {void} + */ +async function loadRecentSubmissions(uid) { + logInfo("📋 Cargando envíos recientes para UID:", uid); + + try { + // Desuscribir listener anterior si existe + if (unsubscribeSubmissions) { + unsubscribeSubmissions(); + unsubscribeSubmissions = null; + } + + // Consultar los últimos 10 resultados del usuario, ordenados por fecha + const resultsQuery = query( + collection(db, 'results'), + where('userId', '==', uid) + ); + + unsubscribeSubmissions = onSnapshot(resultsQuery, async (resultsSnapshot) => { + logDebug(`✅ ${resultsSnapshot.size} resultados encontrados`); + + // Convertir a array y ordenar por fecha (más reciente primero) + const results = []; + resultsSnapshot.forEach(doc => { + const result = doc.data(); + results.push({ + id: doc.id, + ...result, + completedAt: result.completedAt?.toDate() || new Date(0) + }); + }); + + // Ordenar por fecha descendente y tomar los últimos 5 + results.sort((a, b) => b.completedAt - a.completedAt); + const recentResults = results.slice(0, 5); + + logDebug(`📊 Mostrando ${recentResults.length} envíos recientes`); + + // Obtener nombres SOLO de los ejercicios necesarios + const exerciseNames = new Map(); + if (recentResults.length> 0) { + const exerciseIdsNeeded = [...new Set(recentResults.map(r => r.exerciseId))]; + + for (const exerciseId of exerciseIdsNeeded) { + // Intentar obtener del caché primero + let name = getExerciseNameFromCache(exerciseId); + + if (!name) { + // Solo si no está en caché, hacer lectura individual + try { + const exerciseDoc = await getDoc(doc(db, 'exercises', exerciseId)); + if (exerciseDoc.exists()) { + const data = exerciseDoc.data(); + name = data.title || data.name || `Ejercicio ${exerciseId}`; + setExerciseNameCache(exerciseId, name); + } + } catch (error) { + logError(`Error al cargar ejercicio ${exerciseId}:`, error); + name = `Ejercicio ${exerciseId}`; + } + } + + if (name) { + exerciseNames.set(exerciseId, name); + } + } + } + + // Renderizar los envíos + renderRecentSubmissions(recentResults, exerciseNames); + + }, (error) => { + logError("❌ Error al cargar envíos recientes:", error); + }); + + } catch (error) { + logError("❌ Error al configurar listener de envíos recientes:", error); + } +} + + +/** + * Renderiza la lista de envíos recientes en el DOM + * + * @param {Array} results - Array de resultados + * @param {Map} exerciseNames - Mapa de IDs a nombres de ejercicios + * @returns {void} + */ +function renderRecentSubmissions(results, exerciseNames) { + const container = document.getElementById('recentSubmissions'); + if (!container) return; + + if (results.length === 0) { + container.innerHTML = ` +
+ +

No hay envíos recientes

+
+ `; + feather.replace(); + return; + } + + container.innerHTML = results.map(result => { + const testsPassed = parseInt(result.testsPassed) || 0; + const testsFailed = parseInt(result.testsFailed) || 0; + const testsRun = parseInt(result.testsRun) || 0; + const status = result.status === 'success' ? 'success' : (testsPassed> 0 ? 'partial' : 'failed'); + const exerciseName = exerciseNames.get(result.exerciseId) || 'Ejercicio desconocido'; + const timeAgo = formatRelativeTime(result.completedAt); + + // Iconos por estado + const icons = { + success: 'check-circle', + failed: 'x-circle', + partial: 'alert-circle' + }; + + return ` +
+
+ +
+
+
${exerciseName}
+
${timeAgo}
+
+
+
+
${testsPassed}
+
+
+
+
${testsFailed}
+
+
+
+
+ `; + }).join(''); + + // Reemplazar iconos de feather + feather.replace(); +} + + +/** + * Configura la lógica del menú lateral (sidebar) y el botón de logout. + * + * @returns {void} + */ +function setupSidebar() { + const sidebar = document.querySelector('.sidebar'); + const sidebarToggle = document.getElementById('sidebarToggle'); // Desktop + const mobileSidebarToggle = document.getElementById('mobileSidebarToggle'); // Mobile + const sidebarOverlay = document.getElementById('sidebarOverlay'); // Overlay + const submenuItems = document.querySelectorAll('.has-submenu'); + const logoutLink = document.querySelector('.logout-link'); + + const toggleSidebar = () => { + const isCurrentlyCollapsed = sidebar.classList.contains('collapsed'); + sidebar.classList.toggle('collapsed'); + + // Manejar overlay en móviles + const isMobile = window.innerWidth <= 1024; + if (isMobile && sidebarOverlay) { + if (isCurrentlyCollapsed) { + // Sidebar se está abriendo + sidebarOverlay.classList.add('active'); + } else { + // Sidebar se está cerrando + sidebarOverlay.classList.remove('active'); + } + } + + localStorage.setItem('sidebarCollapsed', !isCurrentlyCollapsed); + }; + + // Cerrar sidebar al hacer clic en el overlay + if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', () => { + sidebar.classList.add('collapsed'); + sidebarOverlay.classList.remove('active'); + localStorage.setItem('sidebarCollapsed', true); + }); + } + + if (sidebarToggle) { + sidebarToggle.addEventListener('click', toggleSidebar); + + // A11y: Soporte para teclado + sidebarToggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSidebar(); + } + }); + sidebarToggle.setAttribute('role', 'button'); + sidebarToggle.setAttribute('tabindex', '0'); + sidebarToggle.setAttribute('aria-label', 'Toggle sidebar'); + } + + if (mobileSidebarToggle) { + mobileSidebarToggle.addEventListener('click', toggleSidebar); + + // A11y: Soporte para teclado + mobileSidebarToggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSidebar(); + } + }); + mobileSidebarToggle.setAttribute('role', 'button'); + mobileSidebarToggle.setAttribute('tabindex', '0'); + mobileSidebarToggle.setAttribute('aria-label', 'Toggle mobile sidebar'); + } + + // Cargar estado inicial + const isMobile = window.innerWidth <= 1024; + const savedStateCollapsed = localStorage.getItem('sidebarCollapsed') === 'true'; + if (isMobile) { + sidebar.classList.add('collapsed'); + // Asegurar que el overlay esté oculto + if (sidebarOverlay) { + sidebarOverlay.classList.remove('active'); + } + } else { + if (savedStateCollapsed) { + sidebar.classList.add('collapsed'); + } else { + sidebar.classList.remove('collapsed'); + } + // En desktop nunca mostrar overlay + if (sidebarOverlay) { + sidebarOverlay.classList.remove('active'); + } + } + + // Actualizar aria-expanded + if (sidebar && sidebarToggle) { + sidebarToggle.setAttribute('aria-expanded', !sidebar.classList.contains('collapsed')); + } + + // Manejar resize de ventana para ocultar overlay en desktop + window.addEventListener('resize', () => { + const isMobileNow = window.innerWidth <= 1024; + if (!isMobileNow && sidebarOverlay) { + // Al pasar a desktop, ocultar overlay + sidebarOverlay.classList.remove('active'); + } + }); + + // Submenús con A11y + submenuItems.forEach(item => { + const link = item.querySelector('a'); + link.addEventListener('click', (e) => { + if (!sidebar.classList.contains('collapsed')) { + e.preventDefault(); + const isOpen = item.classList.toggle('submenu-open'); + link.setAttribute('aria-expanded', isOpen); + } else { + if (isMobile) { + e.preventDefault(); + sidebar.classList.remove('collapsed'); + localStorage.setItem('sidebarCollapsed', 'false'); + if (sidebarToggle) sidebarToggle.setAttribute('aria-expanded', 'true'); + } + } + }); + + // A11y: Configurar atributos iniciales + link.setAttribute('aria-expanded', 'false'); + }); + + // Logout con limpieza + if (logoutLink) { + logoutLink.addEventListener('click', async (e) => { + e.preventDefault(); + + try { + cleanupBeforeNavigation(); + await signOut(auth); + logDebug('✅ Usuario cerró sesión manualmente'); + + // Señal para otras pestañas + localStorage.setItem('authLogout', Date.now().toString()); + + window.location.href = 'signin.html'; + } catch (error) { + logError('❌ Error al cerrar sesión:', error.code); + showUserFriendlyError('Error al cerrar sesión. Intenta de nuevo.'); + } + }); + } +} + + +/** + * Carga datos del perfil (nombre y avatar de GitHub) en la UI. + * Implementa cache y retry con exponential backoff para GitHub API. + * + * @param {object} userData - Datos del documento de Firestore. + * @returns {Promise} + */ +async function loadUserData(userData) { + const fullName = `${userData.firstName || ''} ${userData.apellidoPaterno || ''}`.trim(); + const userNameElement = document.getElementById('userName'); + if (userNameElement) userNameElement.textContent = fullName || 'Usuario'; + + const userAvatarElement = document.getElementById('userAvatar'); + const githubUsername = userData.githubUsername; + const defaultAvatar = 'https://via.placeholder.com/40'; + + if (userAvatarElement) { + // Mostrar placeholder inmediatamente + userAvatarElement.src = defaultAvatar; + + if (githubUsername) { + // Verificar cache primero + if (avatarCache.has(githubUsername)) { + const cachedData = avatarCache.get(githubUsername); + const cacheAge = Date.now() - cachedData.timestamp; + + // Cache válido por 1 hora + if (cacheAge < 60 * 60 * 1000) { + userAvatarElement.src = cachedData.url; + logDebug('✅ Avatar cargado desde cache'); + return; + } + } + + // Fetch con retry + const avatarUrl = await fetchGitHubAvatarWithRetry(githubUsername); + if (avatarUrl) { + userAvatarElement.src = avatarUrl; + + // Guardar en cache + avatarCache.set(githubUsername, { + url: avatarUrl, + timestamp: Date.now() + }); + logDebug('✅ Avatar de GitHub cargado y cacheado'); + } + } + } +} + +/** + * Obtiene el avatar de GitHub con retry exponencial. + * + * @param {string} username - Username de GitHub. + * @param {number} [maxRetries=3] - Número máximo de reintentos. + * @returns {Promise} URL del avatar o null si falla. + */ +async function fetchGitHubAvatarWithRetry(username, maxRetries = 3) { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(`https://api.github.com/users/${username}`, { + headers: { + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + if (response.status === 404) { + logWarn(`⚠️ Usuario de GitHub no encontrado: ${username}`); + return null; + } + throw new Error(`GitHub API error: ${response.status}`); + } + + const githubData = await response.json(); + return githubData.avatar_url || null; + + } catch (error) { + logError(`❌ Error al obtener avatar (intento ${attempt + 1}/${maxRetries}):`, error.message); + + // Exponential backoff: esperar antes de reintentar + if (attempt < maxRetries - 1) { + const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + logWarn('⚠️ Fallback a avatar por defecto tras múltiples fallos'); + return null; +} + + +/** + * Anima las estadísticas en la UI. + * + * @param {object} stats - Objeto con las estadísticas. + * @returns {void} + */ +function animateStats(stats) { + animateCounter('testsPassed', 0, stats.testsPassed, 1500); + animateCounter('testsFailed', 0, stats.testsFailed, 1500); + animateCounter('testsTotal', 0, stats.testsTotal, 1500); + animateCounter('successRate', 0, stats.successRate, 2000, '%'); + animateCounter('totalPoints', 0, stats.totalPoints || 0, 1500); + animateCounter('courseProgress', 0, stats.courseProgress, 2000, '%'); + animateCounter('completedExercises', 0, stats.completedExercises, 1500); + + const totalExercisesEl = document.getElementById('totalExercises'); + if (totalExercisesEl) totalExercisesEl.textContent = stats.totalExercises; + + const progressFill = document.getElementById('progressFill'); + if (progressFill) { + progressFill.style.width = '0%'; + requestAnimationFrame(() => { requestAnimationFrame(() => { progressFill.style.width = stats.courseProgress + '%'; }); }); + } + + // Highlight tests failed widget if there are any failures + const testsFailedWidget = document.getElementById('testsFailed')?.closest('.widget'); + if (testsFailedWidget) { + if (stats.testsFailed> 0) { + testsFailedWidget.classList.add('has-failures'); + } else { + testsFailedWidget.classList.remove('has-failures'); + } + } +} + + +/** + * Anima un número de inicio a fin en un elemento HTML. + * + * @param {string} elementId - ID del elemento HTML. + * @param {number} start - Valor inicial. + * @param {number} end - Valor final. + * @param {number} duration - Duración de la animación en ms. + * @param {string} [suffix] - Sufijo opcional para mostrar (ej. '%'). + * @returns {void} + */ +function animateCounter(elementId, start, end, duration, suffix = '') { + const element = document.getElementById(elementId); + if (!element) return; + const finalValue = (isNaN(end) || end === undefined) ? 0 : end; + const range = finalValue - start; + if (range === 0) { element.textContent = start + suffix; return; } + let startTime = null; + function step(timestamp) { + if (!startTime) startTime = timestamp; + const progress = timestamp - startTime; + const current = start + (range * Math.min(progress / duration, 1)); + element.textContent = Math.floor(current) + suffix; + if (progress < duration) { requestAnimationFrame(step); } + else { element.textContent = finalValue + suffix; } + } + requestAnimationFrame(step); +} + + +/** + * Actualiza la información del último commit (simulado). + * + * @param {object} userData - Datos del usuario. + * @returns {void} + */ +function updateLastCommit(userData) { + const lastCommitDateEl = document.getElementById('lastCommitDate'); + const lastActivityTimeEl = document.getElementById('lastActivityTime'); + const lastAttemptTimeEl = document.getElementById('lastAttemptTime'); + const githubUser = userData.githubUsername || 'usuario'; + const now = new Date(); + const simulatedDate = new Date(now - Math.random() * 5 * 60 * 60 * 1000); + const formattedDate = formatRelativeTime(simulatedDate); + if (lastCommitDateEl) lastCommitDateEl.textContent = `Commit de @${githubUser}`; + if (lastActivityTimeEl) lastActivityTimeEl.textContent = formattedDate; + if (lastAttemptTimeEl) { + const attemptDate = new Date(simulatedDate.getTime() + 15 * 60 * 1000); + lastAttemptTimeEl.textContent = formatRelativeTime(attemptDate); + } +} + + +/** + * Formatea una fecha a tiempo relativo. + * + * @param {Date} date - Fecha a formatear. + * @returns {string} Tiempo relativo en formato legible. + */ +function formatRelativeTime(date) { + if (!(date instanceof Date) || isNaN(date)) return "Fecha inválida"; + const now = new Date(); + const diff = now - date; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + if (seconds < 5) return 'Ahora mismo'; + if (minutes < 1) return `Hace ${seconds} seg.`; + if (hours < 1) return `Hace ${minutes} min.`; + if (days < 1) return `Hace ${hours} hr${hours> 1 ? 's' : ''}`; + return `Hace ${days} día${days> 1 ? 's' : ''}`; +} + + +// --- FUNCIONES DE CLEANUP Y UTILIDAD --- + +/** + * Limpia todas las subscripciones y listeners antes de navegar. + * @returns {void} + */ +function cleanupBeforeNavigation() { + // Desuscribir de Firebase listeners + if (unsubscribeSnapshot) { + unsubscribeSnapshot(); + unsubscribeSnapshot = null; + logDebug('✅ Snapshot listener limpiado'); + } + + // Desuscribirse de estadísticas + if (unsubscribeStats) { + unsubscribeStats(); + unsubscribeStats = null; + logDebug('✅ Stats listener limpiado'); + } + + // Desuscribirse de envíos recientes + if (unsubscribeSubmissions) { + unsubscribeSubmissions(); + unsubscribeSubmissions = null; + logDebug('✅ Submissions listener limpiado'); + } + + if (unsubscribeAuth) { + unsubscribeAuth(); + unsubscribeAuth = null; + logDebug('✅ Auth listener limpiado'); + } + + // Remover listeners de inactividad + inactivityListeners.forEach(({ event, handler }) => { + document.removeEventListener(event, handler, { capture: true }); + }); + inactivityListeners = []; + + // Limpiar timers + clearTimeout(inactivityTimer); + clearTimeout(warningTimer); + + // Limpiar modal de inactividad + const modal = document.getElementById('inactivityWarningModal'); + if (modal) { + const interval = modal.dataset.countdownInterval; + if (interval) clearInterval(parseInt(interval)); + modal.remove(); + } + + logDebug('✅ Cleanup completo antes de navegación'); +} + +/** + * Maneja cambios en localStorage (control multi-tab). + * @param {StorageEvent} e - Evento de storage. + * @returns {void} + */ +function handleStorageChange(e) { + // Detectar logout en otra pestaña + if (e.key === 'authLogout') { + logDebug('🔄 Logout detectado en otra pestaña'); + cleanupBeforeNavigation(); + + // Verificar si hay razón de logout + const reason = localStorage.getItem('logoutReason'); + if (reason === 'inactivity') { + alert('Tu sesión ha expirado por inactividad.'); + localStorage.removeItem('logoutReason'); + } + + window.location.href = 'signin.html'; + } +} + +/** + * Muestra un mensaje de error amigable al usuario. + * @param {string} message - Mensaje a mostrar. + * @returns {void} + */ +function showUserFriendlyError(message) { + // Buscar contenedor de errores o crear uno + let errorContainer = document.getElementById('dashboardErrorContainer'); + + if (!errorContainer) { + errorContainer = document.createElement('div'); + errorContainer.id = 'dashboardErrorContainer'; + errorContainer.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + max-width: 400px; + padding: 1rem; + background: #fee; + border-left: 4px solid #e00; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + z-index: 9999; + `; + errorContainer.setAttribute('role', 'alert'); + errorContainer.setAttribute('aria-live', 'assertive'); + document.body.appendChild(errorContainer); + } + + errorContainer.textContent = message; + errorContainer.style.display = 'block'; + + // Auto-ocultar después de 10 segundos + setTimeout(() => { + errorContainer.style.display = 'none'; + }, 10000); +} + + +// --- LÓGICA DE CIERRE DE SESIÓN POR INACTIVIDAD --- +let inactivityTimer; +let warningTimer; +const INACTIVITY_TIMEOUT = 20 * 60 * 1000; // 20 minutos +const WARNING_BEFORE_LOGOUT = 2 * 60 * 1000; // Avisar 2 minutos antes + +/** + * Muestra modal de advertencia antes del logout por inactividad. + * @returns {void} + */ +function showInactivityWarning() { + const remainingTime = 120; // 2 minutos en segundos + let countdown = remainingTime; + + // Crear modal si no existe + let modal = document.getElementById('inactivityWarningModal'); + if (!modal) { + modal = createInactivityModal(); + document.body.appendChild(modal); + } + + const countdownEl = modal.querySelector('#inactivityCountdown'); + const stayLoggedInBtn = modal.querySelector('#stayLoggedInBtn'); + + // Actualizar cuenta regresiva + const countdownInterval = setInterval(() => { + countdown--; + if (countdownEl) { + const minutes = Math.floor(countdown / 60); + const seconds = countdown % 60; + countdownEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + + if (countdown <= 0) { + clearInterval(countdownInterval); + } + }, 1000); + + // Mostrar modal + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + stayLoggedInBtn?.focus(); + + // Botón para mantener sesión + const handleStayLoggedIn = () => { + clearInterval(countdownInterval); + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + resetInactivityTimer(); + stayLoggedInBtn.removeEventListener('click', handleStayLoggedIn); + }; + + stayLoggedInBtn?.addEventListener('click', handleStayLoggedIn); + + // Guardar referencia al intervalo para limpiar después + modal.dataset.countdownInterval = countdownInterval; +} + +/** + * Crea el modal de advertencia de inactividad. + * @returns {HTMLElement} Elemento del modal. + */ +function createInactivityModal() { + const modal = document.createElement('div'); + modal.id = 'inactivityWarningModal'; + modal.className = 'inactivity-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'inactivityModalTitle'); + modal.setAttribute('aria-hidden', 'true'); + + modal.innerHTML = ` +
+

⏱️ Sesión por expirar

+

Tu sesión expirará por inactividad en 2:00

+

¿Deseas continuar trabajando?

+ +
+ `; + + // Estilos inline para asegurar visibilidad + modal.style.cssText = ` + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + align-items: center; + justify-content: center; + `; + + const content = modal.querySelector('.inactivity-modal-content'); + content.style.cssText = ` + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 400px; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + `; + + const btn = modal.querySelector('#stayLoggedInBtn'); + btn.style.cssText = ` + margin-top: 1rem; + padding: 0.75rem 1.5rem; + background: #4f46e5; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + `; + + return modal; +} + +/** + * Cierra la sesión del usuario por inactividad. + * @returns {void} + */ +async function logoutDueToInactivity() { + logDebug("⏱️ Cerrando sesión por inactividad..."); + + // Limpiar modal si existe + const modal = document.getElementById('inactivityWarningModal'); + if (modal) { + const interval = modal.dataset.countdownInterval; + if (interval) clearInterval(parseInt(interval)); + modal.remove(); + } + + try { + cleanupBeforeNavigation(); + await signOut(auth); + localStorage.setItem('logoutReason', 'inactivity'); + localStorage.setItem('authLogout', Date.now().toString()); + window.location.href = 'signin.html'; + } catch (error) { + logError('❌ Error al cerrar sesión por inactividad:', error.code); + } +} + +/** + * Reinicia el temporizador de inactividad. + * @returns {void} + */ +function resetInactivityTimer() { + clearTimeout(inactivityTimer); + clearTimeout(warningTimer); + + // Avisar 2 minutos antes del logout + warningTimer = setTimeout(showInactivityWarning, INACTIVITY_TIMEOUT - WARNING_BEFORE_LOGOUT); + + // Logout después del tiempo completo + inactivityTimer = setTimeout(logoutDueToInactivity, INACTIVITY_TIMEOUT); +} + +/** + * Inicia el monitoreo de inactividad para cerrar sesión automáticamente. + * @returns {void} + */ +function startInactivityMonitoring() { + const activityEvents = ['mousemove', 'mousedown', 'keypress', 'scroll', 'touchstart', 'click']; + + activityEvents.forEach(event => { + const handler = () => resetInactivityTimer(); + document.addEventListener(event, handler, { capture: true, passive: true }); + + // Guardar referencia para limpiar después + inactivityListeners.push({ event, handler }); + }); + + resetInactivityTimer(); + logDebug("✅ Monitoreo de inactividad iniciado (20 min con aviso a los 18 min)"); +} + + +// --- UTILIDADES DE UI/UX --- + +/** + * Muestra skeleton loaders en todos los widgets. + * @returns {void} + */ +function showSkeletonLoaders() { + const widgets = document.querySelectorAll('.widget'); + widgets.forEach(widget => { + widget.classList.add('loading'); + }); +} + +/** + * Oculta skeleton loaders de todos los widgets. + * @returns {void} + */ +function hideSkeletonLoaders() { + const widgets = document.querySelectorAll('.widget'); + widgets.forEach(widget => { + widget.classList.remove('loading'); + }); +} + +/** + * Muestra un toast notification. + * @param {'success'|'error'|'info'} type - Tipo de toast. + * @param {string} title - Título del mensaje. + * @param {string} message - Contenido del mensaje. + * @returns {void} + */ +function showToast(type, title, message) { + // Crear elemento toast + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + + // Determinar icono según tipo + let iconName = 'info'; + if (type === 'success') iconName = 'check-circle'; + if (type === 'error') iconName = 'x-circle'; + + toast.innerHTML = ` +
+ +
+
+
${title}
+
${message}
+
+ + `; + + document.body.appendChild(toast); + + // Reemplazar iconos de feather + if (typeof feather !== 'undefined') { + feather.replace(); + } + + // Event listener para cerrar + const closeBtn = toast.querySelector('.toast-close'); + closeBtn.addEventListener('click', () => removeToast(toast)); + + // Auto-cerrar después de 5 segundos + setTimeout(() => removeToast(toast), 5000); +} + +/** + * Remueve un toast del DOM con animación. + * @param {HTMLElement} toast - Elemento toast a remover. + * @returns {void} + */ +function removeToast(toast) { + if (!toast) return; + toast.style.animation = 'slideInFromRight 0.3s ease-out reverse'; + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); +} + +/** + * Agrega tooltips a elementos específicos. + * @returns {void} + */ +function initializeTooltips() { + // Agregar tooltip al icono de success rate + const successRateWidget = document.querySelector('.widget:nth-child(4)'); + if (successRateWidget) { + const icon = successRateWidget.querySelector('.widget-icon'); + if (icon) { + icon.classList.add('tooltip'); + icon.setAttribute('data-tooltip', 'Porcentaje de tests pasados vs totales'); + } + } + + // Agregar tooltip al progress bar + const progressWidget = document.querySelector('.progress-widget'); + if (progressWidget) { + const icon = progressWidget.querySelector('.widget-icon'); + if (icon) { + icon.classList.add('tooltip'); + icon.setAttribute('data-tooltip', 'Tu progreso general en el curso'); + } + } + + // Agregar tooltips a botones del header + const bellBtn = document.querySelector('.header-actions .action-btn:nth-child(1)'); + if (bellBtn) { + bellBtn.classList.add('tooltip'); + bellBtn.setAttribute('data-tooltip', 'Notificaciones'); + } + + const helpBtn = document.querySelector('.header-actions .action-btn:nth-child(2)'); + if (helpBtn) { + helpBtn.classList.add('tooltip'); + helpBtn.setAttribute('data-tooltip', 'Ayuda y soporte'); + } +} + +// Inicializar tooltips cuando cargue el DOM +document.addEventListener('DOMContentLoaded', () => { + setTimeout(initializeTooltips, 1000); +}); + + +// --- LÓGICA DEL MODAL DE AYUDA --- + +/** + * Inicializa el modal de ayuda/soporte. + * @returns {void} + */ +function initializeHelpModal() { + const helpBtn = document.getElementById('helpBtn'); + const helpModal = document.getElementById('helpModal'); + const helpModalClose = document.getElementById('helpModalClose'); + const copyEmailBtn = document.getElementById('copyEmailBtn'); + + if (!helpBtn || !helpModal) return; + + // Abrir modal + helpBtn.addEventListener('click', () => { + helpModal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Prevenir scroll + + // Reemplazar iconos de feather en el modal + if (typeof feather !== 'undefined') { + feather.replace(); + } + }); + + // Cerrar modal + const closeModal = () => { + helpModal.classList.remove('active'); + document.body.style.overflow = ''; // Restaurar scroll + }; + + if (helpModalClose) { + helpModalClose.addEventListener('click', closeModal); + } + + // Cerrar al hacer clic fuera del contenido + helpModal.addEventListener('click', (e) => { + if (e.target === helpModal) { + closeModal(); + } + }); + + // Cerrar con tecla ESC + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && helpModal.classList.contains('active')) { + closeModal(); + } + }); + + // Funcionalidad de copiar email + if (copyEmailBtn) { + copyEmailBtn.addEventListener('click', async () => { + const email = 'deepdevjose@itsoeh.edu.mx'; + + try { + await navigator.clipboard.writeText(email); + + // Cambiar icono temporalmente + const icon = copyEmailBtn.querySelector('i'); + if (icon) { + const originalIcon = icon.getAttribute('data-feather'); + icon.setAttribute('data-feather', 'check'); + copyEmailBtn.classList.add('copied'); + + // Reemplazar solo los iconos del botón + if (typeof feather !== 'undefined') { + feather.replace(); + } + + // Restaurar icono después de 2 segundos + setTimeout(() => { + icon.setAttribute('data-feather', originalIcon || 'copy'); + copyEmailBtn.classList.remove('copied'); + if (typeof feather !== 'undefined') { + feather.replace(); + } + }, 2000); + } + + // Mostrar toast + showToast('success', 'Copiado', 'Email copiado al portapapeles'); + + } catch (error) { + logError('❌ Error al copiar email:', error); + showToast('error', 'Error', 'No se pudo copiar el email'); + } + }); + } +} + +// Inicializar modal de ayuda cuando cargue el DOM +document.addEventListener('DOMContentLoaded', () => { + initializeHelpModal(); +}); \ No newline at end of file diff --git a/src/js/exercises.js b/src/js/exercises.js new file mode 100644 index 0000000..40ce9e1 --- /dev/null +++ b/src/js/exercises.js @@ -0,0 +1,1260 @@ +/** + * exercises.js - Gestión de ejercicios de Java + * Maneja la carga, filtrado, envío y validación de ejercicios + */ + +// ========================================== +// IMPORTS +// ========================================== +import { auth, db } from './firebase-init.js'; +import { onAuthStateChanged, signOut } from 'https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js'; +import { + collection, + getDocs, + doc, + getDoc, + addDoc, + setDoc, + deleteDoc, + query, + where, + orderBy, + serverTimestamp, + onSnapshot +} from 'https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js'; +import { incrementStat } from './stats-updater.js'; + +// ========================================== +// GLOBAL STATE +// ========================================== +let currentUser = null; +let allExercises = []; +let currentExercise = null; +let currentSubmissionId = null; +let resultsListener = null; +let autoSaveTimeout = null; + +// Cache configuration +const EXERCISES_CACHE_KEY = 'exercises_cache_v1'; +const EXERCISES_CACHE_TTL = 10 * 60 * 1000; // 10 minutos +const USER_PROGRESS_CACHE_TTL = 60 * 1000; // 1 minuto + +// ========================================== +// DOM ELEMENTS +// ========================================== +const elements = { + // Containers + exercisesContainer: document.getElementById('exercisesContainer'), + + // Filters + difficultyToggle: document.getElementById('difficultyToggle'), + categoryFilter: document.getElementById('categoryFilter'), + authorFilter: document.getElementById('authorFilter'), + statusToggle: document.getElementById('statusToggle'), + + // View Toggle + gridViewBtn: document.getElementById('gridViewBtn'), + listViewBtn: document.getElementById('listViewBtn'), + + // Modal + exerciseModal: document.getElementById('exerciseModal'), + closeExerciseModal: document.getElementById('closeExerciseModal'), + exerciseTitle: document.getElementById('exerciseTitle'), + difficultyBadge: document.getElementById('difficultyBadge'), + pointsBadge: document.getElementById('pointsBadge'), + exerciseDescription: document.getElementById('exerciseDescription'), + codeEditor: document.getElementById('codeEditor'), + resetCodeBtn: document.getElementById('resetCodeBtn'), + testCodeBtn: document.getElementById('testCodeBtn'), + submitCodeBtn: document.getElementById('submitCodeBtn'), + + // Results + resultsSection: document.getElementById('resultsSection'), + resultSummary: document.getElementById('resultSummary'), + resultDetails: document.getElementById('resultDetails'), + loadingOverlay: document.getElementById('loadingOverlay'), + + // Sidebar + sidebar: document.getElementById('sidebar'), + sidebarToggle: document.getElementById('sidebarToggle'), + mobileSidebarToggle: document.getElementById('mobileSidebarToggle'), + sidebarOverlay: document.getElementById('sidebarOverlay'), + + // User profile in sidebar + userAvatar: document.getElementById('userAvatar'), + userName: document.getElementById('userName'), + logoutLink: document.getElementById('logoutLink'), + + // User profile in global header + headerUserAvatar: document.getElementById('headerUserAvatar'), + headerUserName: document.getElementById('headerUserName'), + headerLogoutLink: document.getElementById('headerLogoutLink'), + + // Help + helpBtn: document.getElementById('helpBtn'), + helpModal: document.getElementById('helpModal'), + helpModalClose: document.getElementById('helpModalClose'), + copyEmailBtn: document.getElementById('copyEmailBtn') +}; + +// ========================================== +// INITIALIZATION +// ========================================== +document.addEventListener('DOMContentLoaded', () => { + console.log('🚀 Inicializando página de ejercicios...'); + + // Verificar autenticación + onAuthStateChanged(auth, async (user) => { + if (user) { + currentUser = user; + console.log('✅ Usuario autenticado:', user.email); + await initializePage(); + // Verificar si es admin + await checkAdminAccess(); + } else { + console.log('❌ Usuario no autenticado, redirigiendo...'); + window.location.href = '../../index.html'; + } + }); + + // Inicializar event listeners + initializeEventListeners(); + + // Inicializar Feather icons + if (typeof feather !== 'undefined') { + feather.replace(); + } +}); + +// ========================================== +// PAGE INITIALIZATION +// ========================================== +async function initializePage() { + try { + // Cargar perfil del usuario + await loadUserProfile(); + + // Cargar ejercicios + await loadExercises(); + + // Inicializar filtros + populateCategoryFilter(); + populateAuthorFilter(); + + // Renderizar ejercicios + renderExercises(); + + } catch (error) { + console.error('❌ Error al inicializar página:', error); + showToast('error', 'Error', 'No se pudieron cargar los ejercicios'); + } +} + +// ========================================== +// CHECK ADMIN ACCESS +// ========================================== +async function checkAdminAccess() { + try { + if (!currentUser || !currentUser.email) return; + + // Verificar si existe en la colección de admins + const adminDoc = await getDoc(doc(db, 'admins', currentUser.email)); + + if (adminDoc.exists()) { + // Es administrador, mostrar el enlace + const adminMenuItem = document.getElementById('adminMenuItem'); + if (adminMenuItem) { + adminMenuItem.style.display = 'block'; + console.log('✅ Usuario es administrador, mostrando enlace al panel admin'); + } + } + } catch (error) { + console.log('⚠️ Error al verificar acceso de admin:', error.code); + // No hacer nada, simplemente no mostrar el enlace + } +} + +// ========================================== +// LOAD USER PROFILE +// ========================================== +async function loadUserProfile() { + try { + const userDoc = await getDoc(doc(db, 'usuarios', currentUser.uid)); + if (userDoc.exists()) { + const userData = userDoc.data(); + + // Actualizar nombre en ambos lugares + const fullName = `${userData.firstName || ''} ${userData.apellidoPaterno || ''}`.trim(); + + // Actualizar en header global + if (elements.headerUserName) { + elements.headerUserName.textContent = fullName || 'Usuario'; + } + + // Actualizar en sidebar + if (elements.userName) { + elements.userName.textContent = fullName || 'Usuario'; + } + + // Cargar avatar de GitHub + const githubUsername = userData.githubUsername; + if (githubUsername) { + try { + const response = await fetch(`https://api.github.com/users/${githubUsername}`); + if (response.ok) { + const githubData = await response.json(); + + // Actualizar en header global + if (elements.headerUserAvatar) { + elements.headerUserAvatar.src = githubData.avatar_url; + } + + // Actualizar en sidebar + if (elements.userAvatar) { + elements.userAvatar.src = githubData.avatar_url; + } + } + } catch (error) { + console.warn('⚠️ No se pudo cargar avatar de GitHub'); + } + } + } + } catch (error) { + console.error('❌ Error al cargar perfil:', error); + } +} + +// ========================================== +// LOAD EXERCISES +// ========================================== +async function loadExercises() { + try { + console.log('📚 Cargando ejercicios...'); + + // Intentar cargar del caché + try { + const cached = localStorage.getItem(EXERCISES_CACHE_KEY); + if (cached) { + const { data, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < EXERCISES_CACHE_TTL) { + console.log('📦 Cargando ejercicios desde caché'); + allExercises = data; + await loadUserProgress(); + return; + } + } + } catch (cacheError) { + console.warn('⚠️ Error al leer caché:', cacheError); + } + + console.log('🔄 Cargando ejercicios desde Firestore'); + const exercisesRef = collection(db, 'exercises'); + console.log('✅ Referencia a colección creada'); + + // Cargar todos los documentos + const snapshot = await getDocs(exercisesRef); + console.log('✅ Snapshot obtenido, documentos:', snapshot.size); + + allExercises = []; + console.log('🔄 Iterando sobre documentos...'); + snapshot.forEach((doc) => { + console.log('📄 Procesando documento:', doc.id); + const data = doc.data(); + console.log('📦 Datos del documento:', data); + + // Intentar leer el campo points con varios formatos posibles + let points = data.points || data[' points'] || data['"points"'] || 0; + + // Si es string, limpiar y convertir a número + if (typeof points === 'string') { + points = parseInt(points.replace(/['"]/g, ''), 10) || 0; + } + + console.log('💎 Puntos del ejercicio:', points); + + // NO filtrar por isActive, cargar todo + const cleanData = { + id: doc.id, + title: typeof data.title === 'string' ? data.title.replace(/^"|"$/g, '') : data.title, + description: typeof data.description === 'string' ? data.description.replace(/^"|"$/g, '') : data.description, + difficulty: typeof data.difficulty === 'string' ? data.difficulty.replace(/^"|"$/g, '') : data.difficulty, + category: typeof data.category === 'string' ? data.category.replace(/^"|"$/g, '') : data.category, + templateCode: typeof data.templateCode === 'string' ? data.templateCode.replace(/^"|"$/g, '') : data.templateCode, + testCode: data.testCode, + points: points, + order: data.order, + isActive: data.isActive, + tags: data.tags || [], + author: data.author || null, + theoryLink: data.theoryLink || null + }; + console.log('✨ Datos limpios:', cleanData); + allExercises.push(cleanData); + console.log('✅ Ejercicio agregado, total ahora:', allExercises.length); + }); + console.log('🏁 Iteración completada'); + + // Ordenar manualmente por 'order' + allExercises.sort((a, b) => (a.order || 0) - (b.order || 0)); + + console.log(`✅ ${allExercises.length} ejercicios cargados`); + console.log('📋 Ejercicios:', allExercises); + + // Guardar en caché + try { + localStorage.setItem(EXERCISES_CACHE_KEY, JSON.stringify({ + data: allExercises, + timestamp: Date.now() + })); + console.log('📦 Ejercicios guardados en caché'); + } catch (cacheError) { + console.warn('⚠️ Error al guardar caché:', cacheError); + } + + // Cargar progreso del usuario + await loadUserProgress(); + + } catch (error) { + console.error('❌ Error al cargar ejercicios:', error); + throw error; + } +} + +// ========================================== +// LOAD USER PROGRESS +// ========================================== +async function loadUserProgress() { + try { + const cacheKey = `user_progress_${currentUser.uid}`; + + // Intentar cargar del caché + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const { completedIds, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < USER_PROGRESS_CACHE_TTL) { + console.log('📦 Cargando progreso desde caché'); + const completedExercises = new Set(completedIds); + allExercises.forEach(exercise => { + exercise.completed = completedExercises.has(exercise.id); + }); + console.log(`✅ ${completedIds.length} ejercicios completados (caché)`); + return; + } + } + } catch (cacheError) { + console.warn('⚠️ Error al leer caché de progreso:', cacheError); + } + + console.log('🔄 Cargando progreso desde Firestore'); + const resultsRef = collection(db, 'results'); + const q = query( + resultsRef, + where('userId', '==', currentUser.uid), + where('status', '==', 'success') + ); + const snapshot = await getDocs(q); + + const completedIds = []; + const completedExercises = new Set(); + snapshot.forEach((doc) => { + const result = doc.data(); + const exerciseId = result.exerciseId; + if (!completedExercises.has(exerciseId)) { + completedExercises.add(exerciseId); + completedIds.push(exerciseId); + } + }); + + // Guardar en caché + try { + localStorage.setItem(cacheKey, JSON.stringify({ + completedIds, + timestamp: Date.now() + })); + } catch (cacheError) { + console.warn('⚠️ Error al guardar caché de progreso:', cacheError); + } + + // Marcar ejercicios como completados + allExercises.forEach(exercise => { + exercise.completed = completedExercises.has(exercise.id); + }); + + console.log(`✅ ${completedExercises.size} ejercicios completados`); + + // Actualizar estadísticas después de cargar el progreso + updateStatsBar(); + + } catch (error) { + console.error('❌ Error al cargar progreso:', error); + } +} + +// ========================================== +// UPDATE STATS BAR +// ========================================== +function updateStatsBar() { + const completedCount = allExercises.filter(ex => ex.completed).length; + const pendingCount = allExercises.filter(ex => !ex.completed).length; + const totalExercises = allExercises.length; + const progressPercentage = totalExercises> 0 ? Math.round((completedCount / totalExercises) * 100) : 0; + + // Calcular puntos ganados + const totalPoints = allExercises + .filter(ex => ex.completed) + .reduce((sum, ex) => { + let points = ex.points || 0; + // Convertir a número si es string + if (typeof points === 'string') { + points = parseInt(points.replace(/['"]/g, ''), 10) || 0; + } + return sum + points; + }, 0); + + // Actualizar DOM + const completedCountEl = document.getElementById('completedCount'); + const pendingCountEl = document.getElementById('pendingCount'); + const totalPointsEarnedEl = document.getElementById('totalPointsEarned'); + const progressPercentageEl = document.getElementById('progressPercentage'); + + if (completedCountEl) completedCountEl.textContent = completedCount; + if (pendingCountEl) pendingCountEl.textContent = pendingCount; + if (totalPointsEarnedEl) totalPointsEarnedEl.textContent = totalPoints; + if (progressPercentageEl) progressPercentageEl.textContent = `${progressPercentage}%`; + + console.log('📊 Stats actualizados:', { completedCount, pendingCount, totalPoints, progressPercentage }); +} + +// ========================================== +// SEARCH FILTER +// ========================================== +function filterExercisesBySearch(searchTerm) { + const cards = elements.exercisesContainer.querySelectorAll('.exercise-card'); + + cards.forEach(card => { + const title = card.querySelector('h3')?.textContent.toLowerCase() || ''; + const description = card.querySelector('p')?.textContent.toLowerCase() || ''; + const category = card.querySelector('.category-badge')?.textContent.toLowerCase() || ''; + const author = card.dataset.author?.toLowerCase() || ''; + + const matches = title.includes(searchTerm) || + description.includes(searchTerm) || + category.includes(searchTerm) || + author.includes(searchTerm); + + if (matches || searchTerm === '') { + card.style.display = ''; + } else { + card.style.display = 'none'; + } + }); +} + +// ========================================== +// RENDER EXERCISES +// ========================================== +function renderExercises() { + console.log('🎨 Iniciando renderizado de ejercicios'); + console.log('📦 Total ejercicios en allExercises:', allExercises.length); + + // Actualizar barra de estadísticas + updateStatsBar(); + + // Obtener valores de los filtros toggle + const difficultyFilter = elements.difficultyToggle?.querySelector('.toggle-btn.active')?.dataset.value || 'all'; + const categoryFilter = elements.categoryFilter?.value || 'all'; + const authorFilter = elements.authorFilter?.value || 'all'; + const statusFilter = elements.statusToggle?.querySelector('.toggle-btn.active')?.dataset.value || 'all'; + + console.log('🔍 Filtros aplicados:', { difficultyFilter, categoryFilter, authorFilter, statusFilter }); + + // Filtrar ejercicios + let filteredExercises = allExercises.filter(exercise => { + const matchesDifficulty = difficultyFilter === 'all' || exercise.difficulty === difficultyFilter; + const matchesCategory = categoryFilter === 'all' || exercise.category === categoryFilter; + const matchesAuthor = authorFilter === 'all' || exercise.author === authorFilter; + const matchesStatus = statusFilter === 'all' || + (statusFilter === 'completed' && exercise.completed) || + (statusFilter === 'pending' && !exercise.completed); + + return matchesDifficulty && matchesCategory && matchesAuthor && matchesStatus; + }); + + console.log('✨ Ejercicios después del filtrado:', filteredExercises.length); + + // Limpiar contenedor + elements.exercisesContainer.innerHTML = ''; + console.log('🧹 Contenedor limpiado'); + + if (filteredExercises.length === 0) { + console.log('⚠️ No hay ejercicios filtrados, mostrando empty state'); + elements.exercisesContainer.innerHTML = ` +
+ +

No se encontraron ejercicios

+

Intenta cambiar los filtros

+
+ `; + feather.replace(); + return; + } + + console.log('🔨 Creando tarjetas de ejercicios...'); + // Renderizar cada ejercicio + filteredExercises.forEach((exercise, index) => { + console.log(` 📝 Creando tarjeta ${index + 1}:`, exercise.title); + const card = createExerciseCard(exercise); + elements.exercisesContainer.appendChild(card); + }); + + console.log('✅ Tarjetas creadas, reemplazando iconos Feather'); + feather.replace(); + console.log('🎉 Renderizado completado'); +} + +// ========================================== +// CREATE EXERCISE CARD +// ========================================== +function createExerciseCard(exercise) { + const card = document.createElement('div'); + card.className = `exercise-card ${exercise.completed ? 'completed' : ''}`; + card.onclick = () => openExercise(exercise); + + // Agregar data-author para búsqueda + if (exercise.author) { + card.dataset.author = exercise.author; + } + + // Valores por defecto para campos undefined + const title = exercise.title || 'Sin título'; + const description = exercise.description || 'Sin descripción'; + const difficulty = exercise.difficulty || 'medium'; + const category = exercise.category || 'General'; + + // Procesar puntos - asegurar que sea un número + let points = exercise.points || 0; + if (typeof points === 'string') { + points = parseInt(points.replace(/['"]/g, ''), 10) || 0; + } + + // Truncar descripción + const shortDescription = description.split('\n')[0].substring(0, 100) + '...'; + + card.innerHTML = ` +

${title}

+

${shortDescription}

+ ${exercise.author ? `
+ + Por ${exercise.author} +
` : ''} +
+ + + ${getDifficultyText(difficulty)} + + + + ${points} punto${points !== 1 ? 's' : ''} + + + + ${category} + +
+ ${exercise.theoryLink ? `` : ''} +
+ + ${exercise.completed ? 'Completado' : 'Pendiente'} +
+ `; + + return card; +} + +// ========================================== +// OPEN EXERCISE MODAL +// ========================================== +async function openExercise(exercise) { + currentExercise = exercise; + currentSubmissionId = null; + + // Procesar puntos - asegurar que sea un número + let points = exercise.points || 0; + if (typeof points === 'string') { + points = parseInt(points.replace(/['"]/g, ''), 10) || 0; + } + + // Llenar modal con datos del ejercicio + elements.exerciseTitle.textContent = exercise.title; + elements.difficultyBadge.textContent = getDifficultyText(exercise.difficulty); + elements.difficultyBadge.className = `difficulty-badge ${exercise.difficulty}`; + elements.pointsBadge.textContent = `${points} punto${points !== 1 ? 's' : ''}`; + elements.exerciseDescription.textContent = exercise.description; + + // Intentar cargar código guardado previamente + const savedCode = await loadSavedCode(exercise.id); + elements.codeEditor.value = savedCode || exercise.templateCode; + + // Configurar auto-guardado mientras el usuario escribe + elements.codeEditor.removeEventListener('input', handleCodeChange); + elements.codeEditor.addEventListener('input', handleCodeChange); + + // Ocultar resultados + elements.resultsSection.classList.add('hidden'); + + // Mostrar modal + elements.exerciseModal.classList.add('active'); + document.body.style.overflow = 'hidden'; + + // Reemplazar iconos + feather.replace(); +} + +// ========================================== +// CLOSE EXERCISE MODAL +// ========================================== +function closeExercise() { + elements.exerciseModal.classList.remove('active'); + document.body.style.overflow = 'auto'; + + // Guardar código antes de cerrar + if (currentExercise) { + saveCodeDraft(currentExercise.id, elements.codeEditor.value); + } + + // Limpiar listener de input + elements.codeEditor.removeEventListener('input', handleCodeChange); + + // Detener listener de resultados si existe + if (resultsListener) { + resultsListener(); + resultsListener = null; + } +} + +// ========================================== +// AUTO-SAVE CODE FUNCTIONS +// ========================================== + +// Manejar cambios en el editor (debounced) +function handleCodeChange() { + if (!currentExercise) return; + + // Cancelar timeout anterior + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + } + + // Guardar después de 2 segundos de inactividad + autoSaveTimeout = setTimeout(() => { + saveCodeDraft(currentExercise.id, elements.codeEditor.value); + }, 2000); +} + +// Guardar borrador de código en Firestore +async function saveCodeDraft(exerciseId, code) { + if (!currentUser || !exerciseId) return; + + try { + const draftRef = doc(db, 'code_drafts', `${currentUser.uid}_${exerciseId}`); + await setDoc(draftRef, { + userId: currentUser.uid, + exerciseId: exerciseId, + code: code, + lastSaved: serverTimestamp() + }); + console.log('💾 Código guardado automáticamente'); + } catch (error) { + console.error('Error al guardar código:', error); + } +} + +// Cargar código guardado desde Firestore +async function loadSavedCode(exerciseId) { + if (!currentUser || !exerciseId) return null; + + try { + const draftRef = doc(db, 'code_drafts', `${currentUser.uid}_${exerciseId}`); + const draftDoc = await getDoc(draftRef); + + if (draftDoc.exists()) { + const data = draftDoc.data(); + console.log('📂 Código cargado desde borrador guardado'); + return data.code; + } + } catch (error) { + console.error('Error al cargar código guardado:', error); + } + + return null; +} + +// ========================================== +// RESET CODE +// ========================================== +async function resetCode() { + if (currentExercise) { + elements.codeEditor.value = currentExercise.templateCode; + + // Eliminar borrador guardado + try { + const draftRef = doc(db, 'code_drafts', `${currentUser.uid}_${currentExercise.id}`); + await deleteDoc(draftRef); + console.log('🗑️ Borrador eliminado'); + } catch (error) { + console.error('Error al eliminar borrador:', error); + } + + showToast('info', 'Código restablecido', 'El código ha vuelto a la plantilla original'); + } +} + +// ========================================== +// TEST CODE (LOCAL) +// ========================================== +function testCode() { + showToast('info', 'Función en desarrollo', 'La validación local aún no está disponible. Usa "Enviar Solución" para validar en GitHub Actions.'); +} + +// ========================================== +// SUBMIT CODE +// ========================================== +async function submitCode() { + if (!currentExercise) return; + + const studentCode = elements.codeEditor.value.trim(); + + if (!studentCode) { + showToast('error', 'Código vacío', 'Debes escribir tu solución antes de enviar'); + return; + } + + try { + // Mostrar loading + elements.loadingOverlay.classList.remove('hidden'); + elements.submitCodeBtn.disabled = true; + + console.log('📤 Enviando código...'); + + // 1. Guardar submission en Firestore + const submissionData = { + userId: currentUser.uid, + exerciseId: currentExercise.id, + code: studentCode, + status: 'pending', + submittedAt: serverTimestamp() + }; + + const submissionRef = await addDoc(collection(db, 'submissions'), submissionData); + currentSubmissionId = submissionRef.id; + + console.log('✅ Submission guardada:', currentSubmissionId); + + // Incrementar contador de submissions en stats + incrementStat('totalSubmissions').catch(err => console.warn('⚠️ Stat update:', err)); + + // 2. Obtener token de GitHub desde Firestore + const configDoc = await getDoc(doc(db, 'config', 'github')); + if (!configDoc.exists()) { + throw new Error('No se encontró la configuración de GitHub'); + } + + const githubConfig = configDoc.data(); + + // 3. Trigger GitHub Action via repository_dispatch + const response = await fetch(`https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/dispatches`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${githubConfig.token}`, + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event_type: 'validate_submission', + client_payload: { + submissionId: currentSubmissionId, + userId: currentUser.uid, + exerciseId: currentExercise.id, + studentCode: studentCode + } + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error al disparar GitHub Action: ${response.status} ${errorText}`); + } + + console.log('✅ GitHub Action disparado exitosamente'); + + // 4. Escuchar resultados en tiempo real + listenForResults(); + + showToast('success', 'Código enviado', 'Tu solución está siendo validada...'); + + } catch (error) { + console.error('❌ Error al enviar código:', error); + showToast('error', 'Error', error.message || 'No se pudo enviar el código'); + elements.loadingOverlay.classList.add('hidden'); + elements.submitCodeBtn.disabled = false; + } +} + +// ========================================== +// LISTEN FOR RESULTS +// ========================================== +function listenForResults() { + if (!currentSubmissionId) return; + + console.log('👂 Escuchando resultados para:', currentSubmissionId); + + const resultsRef = collection(db, 'results'); + // Filtrar primero por userId (para cumplir reglas de seguridad), luego por submissionId + const q = query( + resultsRef, + where('userId', '==', currentUser.uid), + where('submissionId', '==', currentSubmissionId) + ); + + resultsListener = onSnapshot(q, (snapshot) => { + snapshot.docChanges().forEach((change) => { + if (change.type === 'added') { + const result = change.doc.data(); + console.log('✅ Resultado recibido:', result); + displayResults(result); + } + }); + }, (error) => { + console.error('❌ Error en listener de resultados:', error); + showToast('error', 'Error', 'No se pudieron obtener los resultados'); + }); +} + +// ========================================== +// DISPLAY RESULTS +// ========================================== +function displayResults(result) { + // Ocultar loading + elements.loadingOverlay.classList.add('hidden'); + elements.submitCodeBtn.disabled = false; + + // Mostrar sección de resultados + elements.resultsSection.classList.remove('hidden'); + + // Crear summary + const isSuccess = result.status === 'success'; + elements.resultSummary.innerHTML = ` +
+
${result.testsPassed || 0}
+
Tests Pasados
+
+
+
${result.testsFailed || 0}
+
Tests Fallidos
+
+
+
${result.testsRun || 0}
+
Total Tests
+
+ `; + + // Crear detalles + elements.resultDetails.className = `result-details ${isSuccess ? 'success' : 'failed'}`; + + if (isSuccess) { + elements.resultDetails.innerHTML = ` +

+ + ¡Felicidades! Todos los tests pasaron +

+

+ Has ganado ${currentExercise.points} puntos. + Tu solución es correcta y cumple con todos los requisitos. +

+ `; + + // Actualizar estado del ejercicio + currentExercise.completed = true; + renderExercises(); + + // Actualizar estadísticas + updateStatsBar(); + + // Invalidar caché de progreso + const cacheKey = `user_progress_${currentUser.uid}`; + localStorage.removeItem(cacheKey); + console.log('🗑️ Caché de progreso invalidado'); + + // Incrementar contador de resultados exitosos en stats + incrementStat('successCount').catch(err => console.warn('⚠️ Stat update:', err)); + + showToast('success', '¡Ejercicio completado!', `Has ganado ${currentExercise.points} puntos`); + + } else { + elements.resultDetails.innerHTML = ` +

+ + Algunos tests fallaron +

+
${result.errorReport || 'Revisa tu código e intenta de nuevo'}
+ `; + + showToast('error', 'Tests fallidos', 'Revisa los errores y intenta de nuevo'); + } + + feather.replace(); + + // Scroll a resultados + elements.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +// ========================================== +function populateCategoryFilter() { + const categories = new Set(); + allExercises.forEach(ex => categories.add(ex.category)); + + categories.forEach(category => { + const option = document.createElement('option'); + option.value = category; + option.textContent = category; + elements.categoryFilter.appendChild(option); + }); +} + +function populateAuthorFilter() { + if (!elements.authorFilter) return; + + // Obtener autores únicos + const authors = [...new Set(allExercises + .map(ex => ex.author) + .filter(author => author) + )].sort(); + + // Limpiar y poblar filtro + elements.authorFilter.innerHTML = ''; + authors.forEach(author => { + const option = document.createElement('option'); + option.value = author; + option.textContent = author; + elements.authorFilter.appendChild(option); + }); +} + +// ========================================== + +// ========================================== +// UTILITY FUNCTIONS +// ========================================== +function setView(viewType) { + if (!elements.exercisesContainer) return; + + // Update container class + if (viewType === 'list') { + elements.exercisesContainer.classList.add('list-view'); + } else { + elements.exercisesContainer.classList.remove('list-view'); + } + + // Update button states + if (elements.gridViewBtn && elements.listViewBtn) { + if (viewType === 'list') { + elements.gridViewBtn.classList.remove('active'); + elements.listViewBtn.classList.add('active'); + } else { + elements.gridViewBtn.classList.add('active'); + elements.listViewBtn.classList.remove('active'); + } + } + + // Save preference + localStorage.setItem('exercisesView', viewType); + + // Re-render exercises to adjust layout if needed + feather.replace(); +} + +function getDifficultyText(difficulty) { + const texts = { + easy: 'Fácil', + medium: 'Media', + hard: 'Difícil' + }; + return texts[difficulty] || difficulty; +} + +function showToast(type, title, message) { + // Reutilizar función del dashboard + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + + const iconMap = { + success: 'check-circle', + error: 'alert-circle', + info: 'info' + }; + + toast.innerHTML = ` + +
+ ${title} +

${message}

+
+ + `; + + document.body.appendChild(toast); + feather.replace(); + + setTimeout(() => toast.classList.add('show'), 100); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 5000); +} + +// ========================================== +// EVENT LISTENERS +// ========================================== +function initializeEventListeners() { + // Toggle Filters - Difficulty + if (elements.difficultyToggle) { + const difficultyButtons = elements.difficultyToggle.querySelectorAll('.toggle-btn'); + difficultyButtons.forEach(btn => { + btn.addEventListener('click', () => { + difficultyButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderExercises(); + }); + }); + } + + // Toggle Filters - Status + if (elements.statusToggle) { + const statusButtons = elements.statusToggle.querySelectorAll('.toggle-btn'); + statusButtons.forEach(btn => { + btn.addEventListener('click', () => { + statusButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderExercises(); + }); + }); + } + + // Category Filter (dropdown) + elements.categoryFilter?.addEventListener('change', renderExercises); + + // Author Filter (dropdown) + elements.authorFilter?.addEventListener('change', renderExercises); + + // Clear Filters Button + const clearFiltersBtn = document.getElementById('clearFilters'); + if (clearFiltersBtn) { + clearFiltersBtn.addEventListener('click', () => { + // Reset difficulty toggle + if (elements.difficultyToggle) { + const difficultyButtons = elements.difficultyToggle.querySelectorAll('.toggle-btn'); + difficultyButtons.forEach(b => b.classList.remove('active')); + difficultyButtons[0]?.classList.add('active'); // Set first (Todas) as active + } + + // Reset category dropdown + if (elements.categoryFilter) { + elements.categoryFilter.value = 'all'; + } + + // Reset author dropdown + if (elements.authorFilter) { + elements.authorFilter.value = 'all'; + } + + // Reset status toggle + if (elements.statusToggle) { + const statusButtons = elements.statusToggle.querySelectorAll('.toggle-btn'); + statusButtons.forEach(b => b.classList.remove('active')); + statusButtons[0]?.classList.add('active'); // Set first (Todos) as active + } + + // Clear search + const searchInput = document.getElementById('searchInput'); + if (searchInput) searchInput.value = ''; + + renderExercises(); + showToast('info', 'Filtros limpiados', 'Mostrando todos los ejercicios'); + }); + } + + // View Toggle Buttons + elements.gridViewBtn?.addEventListener('click', () => { + setView('grid'); + }); + + elements.listViewBtn?.addEventListener('click', () => { + setView('list'); + }); + + // Load saved view preference + const savedView = localStorage.getItem('exercisesView') || 'grid'; + setView(savedView); + + // Search Input + const searchInput = document.getElementById('searchInput'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const searchTerm = e.target.value.toLowerCase().trim(); + filterExercisesBySearch(searchTerm); + }); + } + + // Modal + elements.closeExerciseModal?.addEventListener('click', closeExercise); + elements.exerciseModal?.addEventListener('click', (e) => { + if (e.target === elements.exerciseModal) closeExercise(); + }); + + // Actions + elements.resetCodeBtn?.addEventListener('click', resetCode); + elements.testCodeBtn?.addEventListener('click', testCode); + elements.submitCodeBtn?.addEventListener('click', submitCode); + + // Sidebar toggles + elements.sidebarToggle?.addEventListener('click', toggleSidebar); + elements.mobileSidebarToggle?.addEventListener('click', toggleSidebar); + elements.sidebarOverlay?.addEventListener('click', () => { + elements.sidebar?.classList.add('collapsed'); + elements.sidebarOverlay?.classList.remove('active'); + }); + + // Logout - Sidebar + elements.logoutLink?.addEventListener('click', async (e) => { + e.preventDefault(); + try { + await signOut(auth); + window.location.href = '../../index.html'; + } catch (error) { + console.error('Error al cerrar sesión:', error); + } + }); + + // Logout - Global Header + elements.headerLogoutLink?.addEventListener('click', async (e) => { + e.preventDefault(); + try { + await signOut(auth); + window.location.href = '../../index.html'; + } catch (error) { + console.error('Error al cerrar sesión:', error); + } + }); + + // Help Modal + elements.helpBtn?.addEventListener('click', openHelpModal); + elements.helpModalClose?.addEventListener('click', closeHelpModal); + elements.helpModal?.addEventListener('click', (e) => { + if (e.target === elements.helpModal) closeHelpModal(); + }); + elements.copyEmailBtn?.addEventListener('click', copyEmail); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // ESC para cerrar modales + if (e.key === 'Escape') { + if (elements.exerciseModal?.classList.contains('active')) { + closeExercise(); + } else if (elements.helpModal?.classList.contains('active')) { + closeHelpModal(); + } + } + }); +} + +// ========================================== +// SIDEBAR TOGGLE +// ========================================== +function toggleSidebar() { + const isMobile = window.innerWidth <= 768; + elements.sidebar?.classList.toggle('collapsed'); + + if (isMobile && elements.sidebarOverlay) { + const isCollapsed = elements.sidebar?.classList.contains('collapsed'); + if (isCollapsed) { + elements.sidebarOverlay.classList.remove('active'); + } else { + elements.sidebarOverlay.classList.add('active'); + } + } +} + +// ========================================== +// WINDOW RESIZE +// ========================================== +window.addEventListener('resize', () => { + const isMobile = window.innerWidth <= 768; + if (!isMobile && elements.sidebarOverlay) { + elements.sidebarOverlay.classList.remove('active'); + } +}); + +// ========================================== +// HELP MODAL FUNCTIONS +// ========================================== +function openHelpModal() { + if (elements.helpModal) { + elements.helpModal.classList.add('active'); + document.body.style.overflow = 'hidden'; + + // Reemplazar iconos + if (typeof feather !== 'undefined') { + feather.replace(); + } + } +} + +function closeHelpModal() { + if (elements.helpModal) { + elements.helpModal.classList.remove('active'); + document.body.style.overflow = ''; + } +} + +async function copyEmail() { + const email = 'deepdevjose@itsoeh.edu.mx'; + + try { + await navigator.clipboard.writeText(email); + + // Cambiar icono temporalmente + const icon = elements.copyEmailBtn?.querySelector('i'); + if (icon) { + const originalIcon = icon.getAttribute('data-feather'); + icon.setAttribute('data-feather', 'check'); + elements.copyEmailBtn.classList.add('copied'); + + if (typeof feather !== 'undefined') { + feather.replace(); + } + + // Restaurar después de 2 segundos + setTimeout(() => { + icon.setAttribute('data-feather', originalIcon || 'copy'); + elements.copyEmailBtn.classList.remove('copied'); + if (typeof feather !== 'undefined') { + feather.replace(); + } + }, 2000); + } + + showToast('success', 'Copiado', 'Email copiado al portapapeles'); + + } catch (error) { + console.error('❌ Error al copiar email:', error); + showToast('error', 'Error', 'No se pudo copiar el email'); + } +} \ No newline at end of file diff --git a/src/js/firebase-init.js b/src/js/firebase-init.js new file mode 100644 index 0000000..ab48827 --- /dev/null +++ b/src/js/firebase-init.js @@ -0,0 +1,37 @@ +/** + * @file firebase-init.js + * Inicializa la app de Firebase y exporta instancias de Auth y Firestore. + * + * Este archivo importa la configuración secreta y expone los servicios principales + * para ser usados en el resto de la aplicación. + */ + +// Importa la configuración desde tu archivo secreto (que Git ignora) +import { firebaseConfig } from './firebase-config.js'; + +// Importa las herramientas de Firebase +import { initializeApp } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-app.js"; +import { getAuth } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js"; +import { getFirestore } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js"; + +/** + * Instancia principal de la app de Firebase. + * @type {import('firebase/app').FirebaseApp} + */ +const app = initializeApp(firebaseConfig); + +/** + * Instancia de Firebase Auth para autenticación de usuarios. + * @type {import('firebase/auth').Auth} + */ +export const auth = getAuth(app); + +/** + * Instancia de Firestore para base de datos en tiempo real. + * @type {import('firebase/firestore').Firestore} + */ +export const db = getFirestore(app); + +// Inicializar sistema de stats agregados (solo una vez al cargar la app) +import { initializeStats } from './stats-updater.js'; +initializeStats().catch(err => console.warn('⚠️ Stats initialization:', err)); \ No newline at end of file diff --git a/src/js/index.js b/src/js/index.js new file mode 100644 index 0000000..1605350 --- /dev/null +++ b/src/js/index.js @@ -0,0 +1,40 @@ +/** + * Inicializa la animación secuencial de los elementos de la Hero Page. + * Añade la clase 'visible' a cada elemento con un retraso progresivo para crear efecto de entrada. + * + * @file index.js + */ +document.addEventListener('DOMContentLoaded', () => { + + /** + * Elementos que serán animados en la Hero Page. + * @type {Array} + */ + const elementsToAnimate = [ + document.querySelector('.logo'), + document.querySelector('.hero-nav .btn-secondary'), + document.getElementById('hero-title'), + document.querySelector('.title-divider'), + document.getElementById('hero-subtitle'), + document.getElementById('hero-button') + ]; + + /** + * Tiempo inicial de retraso para la animación (en milisegundos). + * @type {number} + */ + let delay = 300; + + // Animamos cada elemento con un retraso incremental para lograr el efecto "staggered". + elementsToAnimate.forEach((element) => { + if (element) { + setTimeout(() => { + element.classList.add('visible'); + }, delay); + + // Incrementamos el retraso para el siguiente elemento (efecto cascada) + delay += 200; + } + }); + +}); \ No newline at end of file diff --git a/src/js/settings.js b/src/js/settings.js new file mode 100644 index 0000000..dc840f9 --- /dev/null +++ b/src/js/settings.js @@ -0,0 +1,777 @@ +/** + * settings.js - Gestión de configuración de usuario + * Maneja actualización de datos personales, cambio de contraseña y eliminación de cuenta + */ + +import { auth, db } from './firebase-init.js'; +import { + onAuthStateChanged, + updatePassword, + EmailAuthProvider, + reauthenticateWithCredential, + deleteUser +} from 'https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js'; +import { + doc, + getDoc, + updateDoc, + deleteDoc, + collection, + query, + where, + getDocs +} from 'https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js'; + +// ========================================== +// LOGGING UTILITIES +// ========================================== + +const isDevelopment = () => window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + +const logDebug = (...args) => { + if (isDevelopment()) console.log(...args); +}; + +const logWarn = (...args) => { + if (isDevelopment()) console.warn(...args); +}; + +const logError = (...args) => { + if (isDevelopment()) console.error(...args); +}; + +// ========================================== +// DOM ELEMENTS +// ========================================== + +const elements = { + // Forms + personalInfoForm: document.getElementById('personalInfoForm'), + securityForm: document.getElementById('securityForm'), + + // Personal Info Inputs + firstName: document.getElementById('firstName'), + middleName: document.getElementById('middleName'), + apellidoPaterno: document.getElementById('apellidoPaterno'), + apellidoMaterno: document.getElementById('apellidoMaterno'), + semester: document.getElementById('semester'), + group: document.getElementById('group'), + + // Security Inputs + currentPassword: document.getElementById('currentPassword'), + newPassword: document.getElementById('newPassword'), + confirmPassword: document.getElementById('confirmPassword'), + + // Password Strength + passwordStrength: document.getElementById('passwordStrength'), + strengthFill: document.getElementById('strengthFill'), + strengthText: document.getElementById('strengthText'), + + // Delete Account Modal + deleteAccountBtn: document.getElementById('deleteAccountBtn'), + deleteAccountModal: document.getElementById('deleteAccountModal'), + closeDeleteModal: document.getElementById('closeDeleteModal'), + cancelDeleteBtn: document.getElementById('cancelDeleteBtn'), + confirmDeleteInput: document.getElementById('confirmDeleteInput'), + confirmDeleteBtn: document.getElementById('confirmDeleteBtn'), + + // Sidebar + userAvatar: document.getElementById('userAvatar'), + userName: document.getElementById('userName'), + sidebarToggle: document.getElementById('sidebarToggle'), + mobileSidebarToggle: document.getElementById('mobileSidebarToggle'), + sidebarOverlay: document.getElementById('sidebarOverlay'), + sidebar: document.querySelector('.sidebar') +}; + +// ========================================== +// SHOW TOAST NOTIFICATION +// ========================================== + +function showToast(type, title, message) { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + + let iconName = 'info'; + if (type === 'success') iconName = 'check-circle'; + if (type === 'error') iconName = 'x-circle'; + + toast.innerHTML = ` +
+ +
+
+
${title}
+
${message}
+
+ + `; + + document.body.appendChild(toast); + + if (typeof feather !== 'undefined') { + feather.replace(); + } + + const closeBtn = toast.querySelector('.toast-close'); + closeBtn.addEventListener('click', () => removeToast(toast)); + + setTimeout(() => removeToast(toast), 5000); +} + +function removeToast(toast) { + if (!toast) return; + toast.style.animation = 'slideInFromRight 0.3s ease-out reverse'; + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); +} + +// ========================================== +// ERROR HANDLING +// ========================================== + +function showFieldError(fieldId, message) { + const errorElement = document.getElementById(`${fieldId}Error`); + const inputElement = document.getElementById(fieldId); + + if (errorElement && inputElement) { + errorElement.textContent = message; + errorElement.classList.add('active'); + inputElement.classList.add('error'); + } +} + +function clearFieldError(fieldId) { + const errorElement = document.getElementById(`${fieldId}Error`); + const inputElement = document.getElementById(fieldId); + + if (errorElement && inputElement) { + errorElement.textContent = ''; + errorElement.classList.remove('active'); + inputElement.classList.remove('error'); + } +} + +function clearAllErrors() { + const errorMessages = document.querySelectorAll('.error-message'); + const errorInputs = document.querySelectorAll('.error'); + + errorMessages.forEach(el => { + el.textContent = ''; + el.classList.remove('active'); + }); + + errorInputs.forEach(el => el.classList.remove('error')); +} + +// ========================================== +// PASSWORD STRENGTH CHECKER +// ========================================== + +function checkPasswordStrength(password) { + if (!password) { + elements.passwordStrength.classList.remove('active'); + return; + } + + elements.passwordStrength.classList.add('active'); + + let strength = 0; + + // Length check + if (password.length>= 8) strength++; + if (password.length>= 12) strength++; + + // Character variety + if (/[a-z]/.test(password)) strength++; + if (/[A-Z]/.test(password)) strength++; + if (/[0-9]/.test(password)) strength++; + if (/[^a-zA-Z0-9]/.test(password)) strength++; + + // Determine strength level + let level, text; + + if (strength <= 2) { + level = 'weak'; + text = 'Débil'; + } else if (strength <= 4) { + level = 'medium'; + text = 'Media'; + } else { + level = 'strong'; + text = 'Fuerte'; + } + + // Update UI + elements.strengthFill.className = `strength-fill ${level}`; + elements.strengthText.className = `strength-text ${level}`; + elements.strengthText.textContent = `Contraseña: ${text}`; +} + +// ========================================== +// TOGGLE PASSWORD VISIBILITY +// ========================================== + +function setupPasswordToggles() { + const toggleButtons = document.querySelectorAll('.toggle-password'); + + toggleButtons.forEach(button => { + button.addEventListener('click', () => { + const targetId = button.getAttribute('data-target'); + const input = document.getElementById(targetId); + + if (input.type === 'password') { + input.type = 'text'; + button.innerHTML = ''; + } else { + input.type = 'password'; + button.innerHTML = ''; + } + + if (typeof feather !== 'undefined') { + feather.replace(); + } + }); + }); +} + +// ========================================== +// LOAD USER DATA +// ========================================== + +async function loadUserData(user) { + try { + logDebug('Cargando datos del usuario...'); + + const userDoc = await getDoc(doc(db, 'usuarios', user.uid)); + + if (userDoc.exists()) { + const userData = userDoc.data(); + + // Cargar datos personales + if (userData.firstName) elements.firstName.value = userData.firstName; + if (userData.middleName) elements.middleName.value = userData.middleName; + if (userData.apellidoPaterno) elements.apellidoPaterno.value = userData.apellidoPaterno; + if (userData.apellidoMaterno) elements.apellidoMaterno.value = userData.apellidoMaterno; + if (userData.semestre) elements.semester.value = userData.semestre; + if (userData.grupo) elements.group.value = userData.grupo; + + // Cargar avatar y nombre en sidebar + if (userData.githubUsername) { + const avatarUrl = `https://github.com/${userData.githubUsername}.png`; + elements.userAvatar.src = avatarUrl; + elements.userName.textContent = userData.githubUsername; + } + + logDebug('✅ Datos cargados correctamente'); + } else { + logError('No se encontraron datos del usuario'); + } + } catch (error) { + logError('Error al cargar datos:', error); + showToast('error', 'Error', 'No se pudieron cargar tus datos'); + } +} + +// ========================================== +// UPDATE PERSONAL INFO +// ========================================== + +async function updatePersonalInfo(e) { + e.preventDefault(); + clearAllErrors(); + + const user = auth.currentUser; + if (!user) { + showToast('error', 'Error', 'No hay sesión activa'); + return; + } + + // Validaciones + const firstName = elements.firstName.value.trim(); + const middleName = elements.middleName.value.trim(); + const apellidoPaterno = elements.apellidoPaterno.value.trim(); + const apellidoMaterno = elements.apellidoMaterno.value.trim(); + const semester = elements.semester.value; + const group = elements.group.value.trim().toUpperCase(); + + let hasErrors = false; + + if (!firstName || firstName.length < 2) { + showFieldError('firstName', 'El primer nombre debe tener al menos 2 caracteres'); + hasErrors = true; + } + + if (!apellidoPaterno || apellidoPaterno.length < 2) { + showFieldError('apellidoPaterno', 'El apellido paterno debe tener al menos 2 caracteres'); + hasErrors = true; + } + + if (!apellidoMaterno || apellidoMaterno.length < 2) { + showFieldError('apellidoMaterno', 'El apellido materno debe tener al menos 2 caracteres'); + hasErrors = true; + } + + if (!semester) { + showFieldError('semester', 'Selecciona tu semestre'); + hasErrors = true; + } + + if (!group || !/^[A-Z]{1,2}$/.test(group)) { + showFieldError('group', 'Ingresa un grupo válido (ej: A, B, C)'); + hasErrors = true; + } + + if (hasErrors) return; + + try { + // Deshabilitar botón + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Guardando...'; + if (typeof feather !== 'undefined') feather.replace(); + + // Actualizar en Firestore + await updateDoc(doc(db, 'usuarios', user.uid), { + firstName: firstName, + middleName: middleName, + apellidoPaterno: apellidoPaterno, + apellidoMaterno: apellidoMaterno, + semestre: semester, + grupo: group + }); + + logDebug('✅ Información personal actualizada'); + showToast('success', '¡Guardado!', 'Tu información ha sido actualizada'); + + // Restaurar botón + submitBtn.disabled = false; + submitBtn.innerHTML = ' Guardar Cambios'; + if (typeof feather !== 'undefined') feather.replace(); + + } catch (error) { + logError('Error al actualizar información:', error); + showToast('error', 'Error', 'No se pudo guardar la información'); + + // Restaurar botón + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = false; + submitBtn.innerHTML = ' Guardar Cambios'; + if (typeof feather !== 'undefined') feather.replace(); + } +} + +// ========================================== +// CHANGE PASSWORD +// ========================================== + +async function changePassword(e) { + e.preventDefault(); + clearAllErrors(); + + const user = auth.currentUser; + if (!user) { + showToast('error', 'Error', 'No hay sesión activa'); + return; + } + + const currentPass = elements.currentPassword.value; + const newPass = elements.newPassword.value; + const confirmPass = elements.confirmPassword.value; + + let hasErrors = false; + + // Validaciones + if (!currentPass) { + showFieldError('currentPassword', 'Ingresa tu contraseña actual'); + hasErrors = true; + } + + if (!newPass || newPass.length < 8) { + showFieldError('newPassword', 'La contraseña debe tener al menos 8 caracteres'); + hasErrors = true; + } + + if (newPass !== confirmPass) { + showFieldError('confirmPassword', 'Las contraseñas no coinciden'); + hasErrors = true; + } + + if (currentPass === newPass) { + showFieldError('newPassword', 'La nueva contraseña debe ser diferente a la actual'); + hasErrors = true; + } + + if (hasErrors) return; + + try { + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Cambiando...'; + if (typeof feather !== 'undefined') feather.replace(); + + // Reautenticar usuario + const credential = EmailAuthProvider.credential(user.email, currentPass); + await reauthenticateWithCredential(user, credential); + + // Cambiar contraseña + await updatePassword(user, newPass); + + logDebug('✅ Contraseña cambiada exitosamente'); + showToast('success', '¡Contraseña Cambiada!', 'Tu contraseña ha sido actualizada'); + + // Limpiar formulario + elements.securityForm.reset(); + elements.passwordStrength.classList.remove('active'); + + submitBtn.disabled = false; + submitBtn.innerHTML = ' Cambiar Contraseña'; + if (typeof feather !== 'undefined') feather.replace(); + + } catch (error) { + logError('Error al cambiar contraseña:', error); + + if (error.code === 'auth/wrong-password') { + showFieldError('currentPassword', 'Contraseña actual incorrecta'); + } else if (error.code === 'auth/weak-password') { + showFieldError('newPassword', 'La contraseña es muy débil'); + } else { + showToast('error', 'Error', 'No se pudo cambiar la contraseña'); + } + + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = false; + submitBtn.innerHTML = ' Cambiar Contraseña'; + if (typeof feather !== 'undefined') feather.replace(); + } +} + +// ========================================== +// DELETE ACCOUNT +// ========================================== + +function showDeleteModal() { + elements.deleteAccountModal.classList.add('active'); + elements.deleteAccountModal.setAttribute('aria-hidden', 'false'); + elements.confirmDeleteInput.value = ''; + elements.confirmDeleteBtn.disabled = true; +} + +function hideDeleteModal() { + elements.deleteAccountModal.classList.remove('active'); + elements.deleteAccountModal.setAttribute('aria-hidden', 'true'); + elements.confirmDeleteInput.value = ''; +} + +function validateDeleteConfirmation() { + const inputValue = elements.confirmDeleteInput.value; + elements.confirmDeleteBtn.disabled = inputValue !== 'Adios vaquero!'; +} + +async function deleteAccount() { + const user = auth.currentUser; + if (!user) return; + + try { + // Primero, pedir al usuario que confirme con su contraseña + const password = prompt('Por seguridad, ingresa tu contraseña actual para confirmar la eliminación de la cuenta:'); + + if (!password) { + showToast('info', 'Cancelado', 'Eliminación de cuenta cancelada'); + return; + } + + elements.confirmDeleteBtn.disabled = true; + elements.confirmDeleteBtn.innerHTML = ' Eliminando...'; + if (typeof feather !== 'undefined') feather.replace(); + + // Re-autenticar al usuario antes de eliminar + try { + const credential = EmailAuthProvider.credential(user.email, password); + await reauthenticateWithCredential(user, credential); + logDebug('✅ Usuario re-autenticado exitosamente'); + } catch (reauthError) { + logError('Error al re-autenticar:', reauthError); + + if (reauthError.code === 'auth/wrong-password') { + showToast('error', 'Contraseña incorrecta', 'La contraseña ingresada no es correcta'); + } else if (reauthError.code === 'auth/too-many-requests') { + showToast('error', 'Demasiados intentos', 'Has intentado demasiadas veces. Espera un momento e intenta de nuevo'); + } else { + showToast('error', 'Error de autenticación', 'No se pudo verificar tu identidad. Intenta de nuevo'); + } + + // Restaurar botón + elements.confirmDeleteBtn.disabled = false; + elements.confirmDeleteBtn.innerHTML = ' Eliminar cuenta'; + if (typeof feather !== 'undefined') feather.replace(); + return; + } + + // 1. PRIMERO: Obtener datos del usuario ANTES de eliminar + const userDocRef = doc(db, 'usuarios', user.uid); + const userDoc = await getDoc(userDocRef); + + let githubUsername = null; + let matricula = null; + + if (userDoc.exists()) { + const userData = userDoc.data(); + githubUsername = userData.githubUsername; + matricula = userData.matricula; + } + + logDebug('🗑️ Iniciando eliminación completa de cuenta...'); + + // 2. Eliminar todos los CODE DRAFTS del usuario + try { + const draftsRef = collection(db, 'code_drafts'); + const draftsQuery = query(draftsRef, where('userId', '==', user.uid)); + const draftsSnapshot = await getDocs(draftsQuery); + + logDebug(`📝 Encontrados ${draftsSnapshot.size} code drafts para eliminar`); + + const draftDeletePromises = []; + draftsSnapshot.forEach((draftDoc) => { + draftDeletePromises.push(deleteDoc(doc(db, 'code_drafts', draftDoc.id))); + }); + + await Promise.all(draftDeletePromises); + logDebug('✅ Todos los code drafts eliminados'); + } catch (error) { + logDebug('⚠️ Error al eliminar code drafts (puede que no existan):', error.code); + } + + // 3. Eliminar todos los SUBMISSIONS del usuario + try { + const submissionsRef = collection(db, 'submissions'); + const submissionsQuery = query(submissionsRef, where('userId', '==', user.uid)); + const submissionsSnapshot = await getDocs(submissionsQuery); + + logDebug(`📄 Encontrados ${submissionsSnapshot.size} submissions para eliminar`); + + const submissionDeletePromises = []; + submissionsSnapshot.forEach((submissionDoc) => { + submissionDeletePromises.push(deleteDoc(doc(db, 'submissions', submissionDoc.id))); + }); + + await Promise.all(submissionDeletePromises); + logDebug('✅ Todos los submissions eliminados'); + } catch (error) { + logDebug('⚠️ Error al eliminar submissions:', error.code); + if (error.code === 'permission-denied') { + throw new Error('No tienes permisos para eliminar tus submissions. Contacta al administrador.'); + } + } + + // 4. Eliminar todos los RESULTS del usuario + try { + const resultsRef = collection(db, 'results'); + const resultsQuery = query(resultsRef, where('userId', '==', user.uid)); + const resultsSnapshot = await getDocs(resultsQuery); + + logDebug(`📊 Encontrados ${resultsSnapshot.size} results para eliminar`); + + const resultsDeletePromises = []; + resultsSnapshot.forEach((resultDoc) => { + resultsDeletePromises.push(deleteDoc(doc(db, 'results', resultDoc.id))); + }); + + await Promise.all(resultsDeletePromises); + logDebug('✅ Todos los results eliminados'); + } catch (error) { + logDebug('⚠️ Error al eliminar results:', error.code); + if (error.code === 'permission-denied') { + throw new Error('No tienes permisos para eliminar tus results. Contacta al administrador.'); + } + } + + // 5. Eliminar mapping de GitHub username si existe + if (githubUsername) { + try { + await deleteDoc(doc(db, 'github_usernames', githubUsername.toLowerCase())); + logDebug('✅ GitHub username mapping eliminado:', githubUsername); + } catch (error) { + logDebug('⚠️ Error al eliminar GitHub mapping:', error.code); + } + } + + // 6. Eliminar mapping de matrícula si existe + if (matricula) { + try { + await deleteDoc(doc(db, 'matriculas', matricula)); + logDebug('✅ Matrícula mapping eliminado:', matricula); + } catch (error) { + logDebug('⚠️ Error al eliminar matrícula mapping:', error.code); + } + } + + // 7. Eliminar documento del usuario + await deleteDoc(userDocRef); + logDebug('✅ Documento de usuario eliminado'); + + // 8. FINALMENTE: Eliminar usuario de Auth + await deleteUser(user); + logDebug('✅ Usuario eliminado de Auth'); + + logDebug('✅ Cuenta eliminada exitosamente - TODO borrado'); + + // Redirigir a página de inicio + window.location.href = '../../index.html'; + + } catch (error) { + logError('Error al eliminar cuenta:', error); + + let errorMessage = 'No se pudo eliminar la cuenta completamente.'; + + if (error.message.includes('permisos')) { + errorMessage = error.message; + } else if (error.code === 'permission-denied') { + errorMessage = 'No tienes permisos suficientes. Verifica las reglas de seguridad de Firestore.'; + } + + showToast('error', 'Error de Permisos', errorMessage); + + // Restaurar botón + elements.confirmDeleteBtn.disabled = false; + elements.confirmDeleteBtn.innerHTML = ' Eliminar cuenta'; + if (typeof feather !== 'undefined') feather.replace(); + } +} + +// ========================================== +// SIDEBAR MANAGEMENT +// ========================================== + +function setupSidebar() { + const sidebar = elements.sidebar; + const sidebarToggle = elements.sidebarToggle; + const mobileSidebarToggle = elements.mobileSidebarToggle; + const sidebarOverlay = elements.sidebarOverlay; + + const toggleSidebar = () => { + const isCurrentlyCollapsed = sidebar.classList.contains('collapsed'); + sidebar.classList.toggle('collapsed'); + + // Manejar overlay en móviles + const isMobile = window.innerWidth <= 1024; + if (isMobile && sidebarOverlay) { + if (isCurrentlyCollapsed) { + sidebarOverlay.classList.add('active'); + } else { + sidebarOverlay.classList.remove('active'); + } + } + + localStorage.setItem('sidebarCollapsed', !isCurrentlyCollapsed); + }; + + if (sidebarToggle) { + sidebarToggle.addEventListener('click', toggleSidebar); + } + + if (mobileSidebarToggle) { + mobileSidebarToggle.addEventListener('click', toggleSidebar); + } + + if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', () => { + sidebar.classList.add('collapsed'); + sidebarOverlay.classList.remove('active'); + localStorage.setItem('sidebarCollapsed', true); + }); + } + + // Cargar estado inicial + const isMobile = window.innerWidth <= 1024; + const savedStateCollapsed = localStorage.getItem('sidebarCollapsed') === 'true'; + + if (isMobile) { + sidebar.classList.add('collapsed'); + if (sidebarOverlay) sidebarOverlay.classList.remove('active'); + } else { + if (savedStateCollapsed) { + sidebar.classList.add('collapsed'); + } + } + + // Manejar resize + window.addEventListener('resize', () => { + const isMobileNow = window.innerWidth <= 1024; + if (!isMobileNow && sidebarOverlay) { + sidebarOverlay.classList.remove('active'); + } + }); +} + +// ========================================== +// INITIALIZATION +// ========================================== + +document.addEventListener('DOMContentLoaded', () => { + logDebug('Inicializando página de configuración...'); + + // Setup sidebar + setupSidebar(); + + // Setup password toggles + setupPasswordToggles(); + + // Setup password strength checker + if (elements.newPassword) { + elements.newPassword.addEventListener('input', (e) => { + checkPasswordStrength(e.target.value); + }); + } + + // Setup delete confirmation input + if (elements.confirmDeleteInput) { + elements.confirmDeleteInput.addEventListener('input', validateDeleteConfirmation); + } + + // Form submissions + if (elements.personalInfoForm) { + elements.personalInfoForm.addEventListener('submit', updatePersonalInfo); + } + + if (elements.securityForm) { + elements.securityForm.addEventListener('submit', changePassword); + } + + // Delete account modal + if (elements.deleteAccountBtn) { + elements.deleteAccountBtn.addEventListener('click', showDeleteModal); + } + + if (elements.closeDeleteModal) { + elements.closeDeleteModal.addEventListener('click', hideDeleteModal); + } + + if (elements.cancelDeleteBtn) { + elements.cancelDeleteBtn.addEventListener('click', hideDeleteModal); + } + + if (elements.confirmDeleteBtn) { + elements.confirmDeleteBtn.addEventListener('click', deleteAccount); + } + + // Auth state listener + onAuthStateChanged(auth, (user) => { + if (user) { + logDebug('✅ Usuario autenticado:', user.email); + loadUserData(user); + } else { + logDebug('❌ No hay usuario autenticado, redirigiendo...'); + window.location.href = 'signin.html'; + } + }); + + logDebug('✅ Página de configuración inicializada'); +}); diff --git a/src/js/signin.js b/src/js/signin.js new file mode 100644 index 0000000..a8a8043 --- /dev/null +++ b/src/js/signin.js @@ -0,0 +1,531 @@ +// Importar módulos de Firebase Auth y Firestore +import { auth, db } from './firebase-init.js'; +import { signInWithEmailAndPassword, setPersistence, browserSessionPersistence, browserLocalPersistence, signOut, sendPasswordResetEmail } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js"; +import { doc, getDoc } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js"; + +/** + * @file signin.js + * Lógica encapsulada para el formulario de inicio de sesión (signin.html), incluyendo modal de restablecimiento. + */ + +// --- CONSTANTES --- +const BUTTON_TEXT = { + SIGNIN: 'Iniciar Sesión', + SIGNING_IN: 'Iniciando sesión...', + RESEND: 'Enviar Enlace', + SENDING: 'Enviando...' +}; + +const MESSAGES = { + EMPTY_IDENTIFIER: 'Ingresa tu correo, matrícula o usuario de GitHub', + SHORT_PASSWORD: 'La contraseña debe tener al menos 6 caracteres', + NOT_FOUND: 'Correo, matrícula o usuario de GitHub no encontrado.', + UNVERIFIED_EMAIL: 'Tu correo electrónico aún no ha sido verificado. Revisa tu bandeja de entrada.', + SUCCESS: '¡Inicio de sesión exitoso! Redirigiendo...', + INVALID_CREDENTIALS: 'Credenciales incorrectas. Verifica tus datos.', + TOO_MANY_REQUESTS: 'Demasiados intentos. Intenta más tarde.', + USER_NOT_FOUND: 'No existe una cuenta con este correo electrónico.', + WRONG_PASSWORD: 'La contraseña es incorrecta. Inténtalo de nuevo.', + INVALID_EMAIL: 'El formato del correo electrónico no es válido.', + NETWORK_ERROR: 'Error de conexión. Verifica tu conexión a internet.', + GENERIC_ERROR: 'Error al iniciar sesión.' +}; + +document.addEventListener('DOMContentLoaded', () => { + + // --- SELECCIÓN DE ELEMENTOS (Centralizado) --- + const DOM = { + signinForm: document.getElementById('registerForm'), + emailInput: document.getElementById('email'), + passwordInput: document.getElementById('password'), + submitBtn: null, // Se asignará después + togglePwdBtns: document.querySelectorAll('.toggle-password'), + formInputs: null, // Se asignará después + keepLoggedInCheckbox: document.getElementById('keepLoggedIn'), + forgotPasswordLink: document.getElementById('forgotPasswordLink'), + resetPasswordModal: document.getElementById('resetPasswordModal'), + closeModalBtn: document.getElementById('closeModalBtn'), + resetEmailInput: document.getElementById('resetEmail'), + sendResetEmailBtn: document.getElementById('sendResetEmailBtn'), + modalAlertContainer: document.getElementById('modalAlertContainer') + }; + + if (!DOM.signinForm) { + console.error('❌ Error crítico: Formulario de signin no encontrado en el DOM'); + return; + } + + DOM.submitBtn = DOM.signinForm.querySelector('.submit-btn'); + DOM.formInputs = Array.from(DOM.signinForm.querySelectorAll('input:not([type="checkbox"]), select')); + + // --- LÓGICA DE NEGOCIO Y EVENTOS --- + + /** + * Envía el formulario de inicio de sesión, gestiona persistencia, validación y redirección. + * + * @param {Event} e - Evento submit del formulario. + * @returns {Promise} + */ + const handleFormSubmit = async (e) => { + e.preventDefault(); + const loginIdentifier = DOM.emailInput.value.trim(); + const password = DOM.passwordInput.value; + + // Validaciones simples + if (loginIdentifier.length === 0) { + showAlert('error', MESSAGES.EMPTY_IDENTIFIER, DOM.signinForm); + return; + } + if (password.length < 6) { + showAlert('error', MESSAGES.SHORT_PASSWORD, DOM.signinForm); + return; + } + + setLoading(true); + + try { + // 1. Establecer la persistencia + const persistenceType = DOM.keepLoggedInCheckbox && DOM.keepLoggedInCheckbox.checked + ? browserLocalPersistence + : browserSessionPersistence; + await setPersistence(auth, persistenceType); + logDebug(`Persistencia establecida a: ${DOM.keepLoggedInCheckbox && DOM.keepLoggedInCheckbox.checked ? 'local' : 'session'}`); + + // 2. Obtener el email real + const email = await getEmailFromIdentifier(loginIdentifier); + if (!email) { + showAlert('error', MESSAGES.NOT_FOUND, DOM.signinForm); + setLoading(false); + return; + } + + // 3. Iniciar sesión + logDebug('Usuario encontrado. Iniciando sesión...'); + const userCredential = await signInWithEmailAndPassword(auth, email, password); + logDebug('Inicio de sesión exitoso'); + + // 4. Comprobar verificación de correo + if (!userCredential.user.emailVerified) { + logWarn("Intento de login con correo no verificado"); + showAlert('error', MESSAGES.UNVERIFIED_EMAIL, DOM.signinForm); + await signOut(auth); + logDebug("Usuario deslogueado por correo no verificado"); + setLoading(false); + return; + } + + // 5. Éxito y redirección + showAlert('success', MESSAGES.SUCCESS, DOM.signinForm); + setTimeout(() => { + window.location.href = 'dashboard.html'; + }, 2000); + + } catch (error) { + logError("Error en Sign In:", error.code); + let message = MESSAGES.GENERIC_ERROR; + + // Mapeo completo de códigos de error de Firebase Auth + switch (error.code) { + case 'auth/invalid-credential': + message = MESSAGES.INVALID_CREDENTIALS; + break; + case 'auth/user-not-found': + message = MESSAGES.USER_NOT_FOUND; + break; + case 'auth/wrong-password': + message = MESSAGES.WRONG_PASSWORD; + break; + case 'auth/invalid-email': + message = MESSAGES.INVALID_EMAIL; + break; + case 'auth/too-many-requests': + message = MESSAGES.TOO_MANY_REQUESTS; + break; + case 'auth/network-request-failed': + message = MESSAGES.NETWORK_ERROR; + break; + default: + message = MESSAGES.GENERIC_ERROR; + } + + showAlert('error', message, DOM.signinForm); + } finally { + setLoading(false); + } + }; + + /** + * Muestra el modal de restablecimiento de contraseña y precarga el email si está disponible. + */ + const showResetModal = () => { + if (DOM.resetPasswordModal && DOM.resetEmailInput) { + DOM.resetEmailInput.value = DOM.emailInput.value.trim(); + DOM.modalAlertContainer.innerHTML = ''; + DOM.resetPasswordModal.classList.add('visible'); + DOM.resetEmailInput.focus(); + } + }; + + /** + * Oculta el modal de restablecimiento de contraseña. + */ + const hideResetModal = () => { + if (DOM.resetPasswordModal) { + DOM.resetPasswordModal.classList.remove('visible'); + } + }; + + /** + * Envía el correo de restablecimiento de contraseña desde el modal. + * + * @returns {Promise} + */ + const handleSendResetEmail = async () => { + const email = DOM.resetEmailInput.value.trim(); + + if (!email) { + showAlertInModal('error', 'Por favor, ingresa tu correo electrónico.'); + DOM.resetEmailInput.focus(); + return; + } + if (!/\S+@\S+\.\S+/.test(email)) { + showAlertInModal('error', 'Ingresa un correo electrónico válido.'); + DOM.resetEmailInput.focus(); + return; + } + + DOM.sendResetEmailBtn.disabled = true; + DOM.sendResetEmailBtn.textContent = BUTTON_TEXT.SENDING; + DOM.modalAlertContainer.innerHTML = ''; + + try { + await sendPasswordResetEmail(auth, email); + showAlertInModal('success', `Correo enviado a ${email}. Revisa tu bandeja de entrada (y spam).`); + } catch (error) { + logError("Error al enviar correo de restablecimiento:", error.code); + // No revelamos si el usuario existe (práctica de seguridad) + const message = 'Si tu correo está registrado, recibirás un enlace.'; + showAlertInModal('info', message); + } finally { + DOM.sendResetEmailBtn.disabled = false; + DOM.sendResetEmailBtn.textContent = BUTTON_TEXT.RESEND; + } + }; + + /** + * Muestra una alerta dentro del modal de restablecimiento usando DOM API seguro. + * + * @param {'success'|'info'|'error'} type - Tipo de alerta. + * @param {string} message - Mensaje a mostrar. + */ + function showAlertInModal(type, message) { + const alertDiv = document.createElement('div'); + const cssClass = type === 'success' ? 'success' : (type === 'info' ? 'info' : 'error'); + alertDiv.className = `message ${cssClass} show alert-message`; + + const iconSpan = document.createElement('span'); + iconSpan.style.marginRight = '8px'; + const icon = type === 'success' ? '✅' : (type === 'info' ? 'i️' : '❌'); + iconSpan.textContent = icon; + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + + alertDiv.appendChild(iconSpan); + alertDiv.appendChild(messageSpan); + + DOM.modalAlertContainer.innerHTML = ''; + DOM.modalAlertContainer.appendChild(alertDiv); + } + + /** + * Busca en Firestore el email de un usuario a partir de un identificador (email, matrícula o GitHub username). + * Usa colecciones de mapeo para garantizar O(1) lookup y unicidad. + * + * @param {string} identifier - Email, matrícula o username de GitHub. + * @returns {Promise} Email encontrado o null si no existe. + * @throws {Error} Si ocurre un error de permisos en Firestore. + */ + async function getEmailFromIdentifier(identifier) { + try { + // Si parece un email válido, usarlo directamente + if (validateEmail(identifier)) { + logDebug('Identificador es un email válido'); + return identifier; + } + + // Si es solo números, buscar por matrícula en colección de mapeo + if (/^[0-9]+$/.test(identifier)) { + logDebug('Buscando usuario por matrícula en mapeo...'); + const matriculaRef = doc(db, 'matriculas', identifier); + const matriculaDoc = await getDoc(matriculaRef); + + if (matriculaDoc.exists()) { + const email = matriculaDoc.data().email; + logDebug('Usuario encontrado por matrícula'); + return email; + } + logDebug('No se encontró usuario con esa matrícula'); + return null; + } + + // Si no es email ni matrícula, buscar por username de GitHub en colección de mapeo + logDebug('Buscando usuario por GitHub username en mapeo...'); + const githubRef = doc(db, 'github_usernames', identifier.toLowerCase()); + const githubDoc = await getDoc(githubRef); + + if (githubDoc.exists()) { + const email = githubDoc.data().email; + logDebug('Usuario encontrado por GitHub username'); + return email; + } + logDebug('No se encontró usuario con ese GitHub username'); + + return null; + } catch (error) { + logError('Error al buscar usuario:', error.code); + + // Si el error es de permisos, dar instrucciones claras + if (error.code === 'permission-denied') { + logError('Error de permisos en Firestore'); + logError('Verifica las reglas de seguridad en Firebase Console'); + } + + return null; + } + } + + /** + * Controla el estado visual y funcional del botón de submit principal. + * + * @param {boolean} isLoading - Si está cargando o no. + */ + const setLoading = (isLoading) => { + if (!DOM.submitBtn) return; + if (isLoading) { + DOM.submitBtn.disabled = true; + DOM.submitBtn.textContent = BUTTON_TEXT.SIGNING_IN; + DOM.submitBtn.style.opacity = '0.7'; + DOM.submitBtn.style.cursor = 'not-allowed'; + } else { + DOM.submitBtn.disabled = false; + DOM.submitBtn.textContent = BUTTON_TEXT.SIGNIN; + DOM.submitBtn.style.opacity = '1'; + DOM.submitBtn.style.cursor = 'pointer'; + } + }; + + /** + * Permite navegar entre inputs del formulario usando Enter. + * + * @param {KeyboardEvent} e - Evento de teclado. + * @param {number} currentIndex - Índice del input actual. + */ + const handleEnterKeyNavigation = (e, currentIndex) => { + if (e.key === 'Enter') { + e.preventDefault(); + const nextIndex = currentIndex + 1; + if (nextIndex < DOM.formInputs.length) { + DOM.formInputs[nextIndex].focus(); + } else { + DOM.signinForm.requestSubmit(); + } + } + }; + + /** + * Alterna la visibilidad de la contraseña en el input correspondiente usando DOM API seguro. + * + * @param {MouseEvent} e - Evento click del botón de toggle. + */ + const togglePasswordVisibility = (e) => { + e.preventDefault(); + e.stopPropagation(); + + const btn = e.currentTarget; + const passwordInputContainer = btn.closest('.password-input'); + if (!passwordInputContainer) return; + + const input = passwordInputContainer.querySelector('input[type="password"], input[type="text"]'); + if (!input) return; + + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + + // Crear iconos SVG usando createElementNS (más seguro que innerHTML) + btn.innerHTML = ''; // Limpiar contenido + const svg = createSVGIcon(isPassword ? 'eye-closed' : 'eye-open'); + btn.appendChild(svg); + }; + + // --- ASIGNACIÓN DE EVENT LISTENERS --- + DOM.signinForm.addEventListener('submit', handleFormSubmit); + + if (DOM.forgotPasswordLink) { + DOM.forgotPasswordLink.addEventListener('click', (e) => { + e.preventDefault(); + showResetModal(); + }); + } + + if (DOM.closeModalBtn) { + DOM.closeModalBtn.addEventListener('click', hideResetModal); + } + + if (DOM.resetPasswordModal) { + DOM.resetPasswordModal.addEventListener('click', (e) => { + if (e.target === DOM.resetPasswordModal) { + hideResetModal(); + } + }); + } + + if (DOM.sendResetEmailBtn && DOM.resetEmailInput) { + DOM.sendResetEmailBtn.addEventListener('click', handleSendResetEmail); + DOM.resetEmailInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + handleSendResetEmail(); + } + }); + } + + DOM.togglePwdBtns.forEach(btn => btn.addEventListener('click', togglePasswordVisibility)); + DOM.formInputs.forEach((input, index) => input.addEventListener('keypress', (e) => handleEnterKeyNavigation(e, index))); + +}); + +// --- FUNCIONES DE UTILIDAD --- + +/** + * Crea un SVG icon de forma segura usando DOM API. + * + * @param {'eye-open'|'eye-closed'|'success'|'info'|'error'} type - Tipo de icono. + * @returns {SVGElement} Elemento SVG. + */ +function createSVGIcon(type) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '20'); + svg.setAttribute('height', '20'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + + if (type === 'eye-open') { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'); + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', '12'); + circle.setAttribute('cy', '12'); + circle.setAttribute('r', '3'); + svg.appendChild(path); + svg.appendChild(circle); + } else if (type === 'eye-closed') { + const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path1.setAttribute('d', 'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24'); + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', '1'); + line.setAttribute('y1', '1'); + line.setAttribute('x2', '23'); + line.setAttribute('y2', '23'); + svg.appendChild(path1); + svg.appendChild(line); + } + + return svg; +} + +/** + * Muestra una alerta de éxito, info o error antes del formulario especificado usando DOM API seguro. + * + * @param {'success'|'info'|'error'} type - Tipo de alerta. + * @param {string} message - Mensaje a mostrar. + * @param {HTMLFormElement} formElement - Formulario donde se muestra la alerta. + */ +function showAlert(type, message, formElement) { + const parentContainer = formElement.parentNode; + if (!parentContainer) return; + + const existingAlert = parentContainer.querySelector('.alert-message'); + if (existingAlert) { + existingAlert.remove(); + } + + const alertDiv = document.createElement('div'); + const cssClass = type === 'success' ? 'success' : (type === 'info' ? 'info' : 'error'); + alertDiv.className = `message ${cssClass} show alert-message`; + alertDiv.style.display = 'block'; + alertDiv.setAttribute('role', 'alert'); + + // Crear icono SVG de forma segura + const iconContainer = document.createElement('span'); + iconContainer.style.verticalAlign = 'middle'; + iconContainer.style.marginRight = '8px'; + iconContainer.textContent = type === 'success' ? '✅' : (type === 'info' ? 'i️' : '❌'); + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + + alertDiv.appendChild(iconContainer); + alertDiv.appendChild(messageSpan); + parentContainer.insertBefore(alertDiv, formElement); + + setTimeout(() => { + alertDiv.classList.remove('show'); + setTimeout(() => alertDiv.remove(), 500); + }, 5000); +} + +/** + * Valida si un string es un correo electrónico válido. + * + * @param {string} email - Email a validar. + * @returns {boolean} True si el email es válido, false si no. + */ +function validateEmail(email) { + const emailRegex = /^[a-zA-Z0-9.+_-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailRegex.test(String(email).toLowerCase()); +} + +// --- LOGGING UTILITIES (Centralizadas para control en producción) --- + +/** + * Log de debug (solo en desarrollo). + * @param {...any} args - Argumentos a loguear. + */ +function logDebug(...args) { + if (isDevelopment()) { + console.log('✅', ...args); + } +} + +/** + * Log de warning. + * @param {...any} args - Argumentos a loguear. + */ +function logWarn(...args) { + if (isDevelopment()) { + console.warn('⚠️', ...args); + } +} + +/** + * Log de error (siempre visible pero sin detalles sensibles en producción). + * @param {...any} args - Argumentos a loguear. + */ +function logError(...args) { + if (isDevelopment()) { + console.error('❌', ...args); + } else { + // En producción, loguear solo mensajes genéricos + console.error('Error occurred'); + } +} + +/** + * Verifica si estamos en modo desarrollo. + * @returns {boolean} True si es desarrollo. + */ +function isDevelopment() { + return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +} \ No newline at end of file diff --git a/src/js/signup.js b/src/js/signup.js new file mode 100644 index 0000000..5e0cdcc --- /dev/null +++ b/src/js/signup.js @@ -0,0 +1,602 @@ +// Importar módulos de Firebase Auth y Firestore +import { auth, db } from './firebase-init.js'; +import { createUserWithEmailAndPassword, sendEmailVerification } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js"; +import { doc, setDoc, runTransaction, serverTimestamp } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js"; +import { isAdminEmail, DEFAULT_ADMIN_PERMISSIONS } from './admin-config.js'; +import { incrementStat } from './stats-updater.js'; + +/** + * @file signup.js + * Lógica encapsulada para el formulario de registro (signup.html). + */ + +// --- UTILIDADES DE LOGGING (Entorno-aware) --- +/** + * Detecta si la aplicación corre en entorno de desarrollo. + * @returns {boolean} True si está en localhost o 127.0.0.1 + */ +const isDevelopment = () => { + return window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'; +}; + +/** + * Log de depuración (solo en desarrollo). + * @param {...any} args - Argumentos a loguear + */ +const logDebug = (...args) => { + if (isDevelopment()) console.log(...args); +}; + +/** + * Log de advertencia (solo en desarrollo). + * @param {...any} args - Argumentos a loguear + */ +const logWarn = (...args) => { + if (isDevelopment()) console.warn(...args); +}; + +/** + * Log de error (solo en desarrollo, sin exponer detalles en producción). + * @param {...any} args - Argumentos a loguear + */ +const logError = (...args) => { + if (isDevelopment()) console.error(...args); +}; + +document.addEventListener('DOMContentLoaded', () => { + + // --- SELECCIÓN DE ELEMENTOS --- + const registerForm = document.getElementById('registerForm'); + if (!registerForm) return; + + const emailInput = document.getElementById('email'); + const githubInput = document.getElementById('githubUsername'); + const matriculaInput = document.getElementById('matricula'); + const grupoInput = document.getElementById('grupo'); + const togglePwdBtns = document.querySelectorAll('.toggle-password'); + const allFormInputs = Array.from(registerForm.querySelectorAll('input, select')); + const submitBtn = registerForm.querySelector('.submit-btn'); + + // --- ESTADO DE LA APLICACIÓN --- + let githubTimeout; + let githubAbortController = null; + let isSubmitting = false; + + // --- LÓGICA DE NEGOCIO Y EVENTOS --- + + /** + * Envía el formulario de registro, valida datos, crea usuario en Firebase Auth y Firestore, y gestiona redirección. + * Implementa rollback si falla la escritura en Firestore. + * + * @param {Event} e - Evento submit del formulario. + * @returns {Promise} + */ + const handleRegisterSubmit = async (e) => { + e.preventDefault(); + + // Prevenir doble envío + if (isSubmitting) { + logWarn('⚠️ Intento de doble submit bloqueado'); + return; + } + + isSubmitting = true; + setLoading(true); + + const formData = { + firstName: document.getElementById('firstName').value.trim(), + middleName: document.getElementById('middleName').value.trim(), + apellidoPaterno: document.getElementById('apellidoPaterno').value.trim(), + apellidoMaterno: document.getElementById('apellidoMaterno').value.trim(), + matricula: matriculaInput.value.trim(), + grupo: grupoInput.value.trim(), + semestre: document.getElementById('semestre').value, + email: emailInput.value.trim(), + githubUsername: githubInput.value.trim(), + password: document.getElementById('password').value + }; + + // --- Validaciones --- + if (!validateEmail(formData.email)) { + showMessage('error', 'Por favor, usa una dirección de Gmail (@gmail.com) o institucional de ITSOEH (@itsoeh.edu.mx)'); + setAccessibilityError(emailInput, 'emailError'); + resetSubmitState(); + return; + } + if (formData.password.length < 6) { + showMessage('error', 'La contraseña debe tener al menos 6 caracteres'); + const passwordInput = document.getElementById('password'); + if (passwordInput) { + setAccessibilityError(passwordInput, null); + passwordInput.focus(); + } + resetSubmitState(); + return; + } + if (formData.matricula.length === 0) { + showMessage('error', 'Por favor, ingresa tu matrícula'); + setAccessibilityError(matriculaInput, null); + resetSubmitState(); + return; + } + if (formData.grupo.length === 0) { + showMessage('error', 'Por favor, ingresa tu grupo (A, B, C, etc.)'); + setAccessibilityError(grupoInput, null); + resetSubmitState(); + return; + } + const isGitHubValid = await validateGitHub(formData.githubUsername); + if (!isGitHubValid) { + showMessage('error', 'Usuario de GitHub no válido. Por favor, verifícalo.'); + setAccessibilityError(githubInput, 'githubError'); + resetSubmitState(); + return; + } + + // --- INTEGRACIÓN REAL DE FIREBASE CON ROLLBACK --- + let userCredential = null; + try { + // 1. Crear usuario en Auth + userCredential = await createUserWithEmailAndPassword(auth, formData.email, formData.password); + const user = userCredential.user; + logDebug('✅ Usuario creado en Auth:', user.uid); + + // 2. Enviar correo de verificación + try { + await sendEmailVerification(user); + logDebug('✅ Correo de verificación enviado'); + } catch (verificationError) { + logWarn("⚠️ Error al enviar correo de verificación:", verificationError.code); + } + + // 3. Reservar githubUsername y matrícula con transacción atómica + delete formData.password; + try { + await reserveUniqueIdentifiers(user.uid, formData.githubUsername, formData.matricula, formData); + logDebug('✅ Datos guardados en Firestore con identificadores únicos'); + } catch (reservationError) { + logError('❌ Error crítico al reservar identificadores, ejecutando rollback:', reservationError.message); + + // ROLLBACK: Borrar usuario de Auth si falla la reserva + try { + await user.delete(); + logDebug('✅ Rollback exitoso: usuario eliminado de Auth'); + } catch (deleteError) { + logError('❌ Fallo crítico en rollback:', deleteError.code); + } + + // Propagar el error con mensaje específico + throw reservationError; + } + + // 4. Éxito y redirección a verificación + const email = emailInput.value.trim(); + const isAdmin = isAdminEmail(email); + + if (isAdmin) { + showMessage('success', '¡Cuenta de ADMINISTRADOR creada! Revisa tu correo para verificarla. Tendrás acceso al Panel de Admin.'); + } else { + showMessage('success', '¡Cuenta creada! Revisa tu correo para verificarla.'); + } + + setTimeout(() => { + window.location.href = 'verify-email.html'; + }, 3000); + + } catch (error) { + // --- MANEJO DE ERRORES --- + logError("❌ Error en Sign Up:", error.code || error.message); + let message = 'No se pudo crear la cuenta.'; + + // Errores de reserva de identificadores únicos + if (error.message && error.message.includes('GitHub username ya está en uso')) { + message = 'Este usuario de GitHub ya está registrado. Por favor, usa otro.'; + setAccessibilityError(githubInput, 'githubError'); + } else if (error.message && error.message.includes('Matrícula ya está en uso')) { + message = 'Esta matrícula ya está registrada. Contacta a soporte si esto es un error.'; + setAccessibilityError(matriculaInput, null); + } else { + // Firebase Auth errors + switch(error.code) { + case 'auth/email-already-in-use': + message = 'Este correo electrónico ya está en uso.'; + setAccessibilityError(emailInput, 'emailError'); + break; + case 'auth/weak-password': + message = 'La contraseña es muy débil (debe tener al menos 6 caracteres).'; + break; + case 'auth/invalid-email': + message = 'El formato del correo electrónico no es válido.'; + setAccessibilityError(emailInput, 'emailError'); + break; + case 'auth/operation-not-allowed': + message = 'El registro está temporalmente deshabilitado.'; + break; + case 'auth/network-request-failed': + message = 'Error de conexión. Verifica tu internet.'; + break; + default: + // Error genérico o del rollback + if (error.message && !error.code) { + message = error.message; + } + } + } + + showMessage('error', message); + } finally { + resetSubmitState(); + } + }; + + /** + * Resetea el estado de envío del formulario. + */ + const resetSubmitState = () => { + isSubmitting = false; + setLoading(false); + }; + + /** + * Controla el estado visual y funcional del botón de submit. + * + * @param {boolean} isLoading - Si está cargando o no. + */ + const setLoading = (isLoading) => { + if (!submitBtn) return; + if (isLoading) { + submitBtn.disabled = true; + submitBtn.classList.add('loading'); + if (!submitBtn.dataset.originalText) { + submitBtn.dataset.originalText = submitBtn.textContent; + } + submitBtn.textContent = ''; + } else { + submitBtn.disabled = false; + submitBtn.classList.remove('loading'); + submitBtn.textContent = submitBtn.dataset.originalText || 'Registrarse'; + } + }; + + /** + * Filtra el input de grupo (solo una letra mayúscula A-Z). + */ + const handleGrupoInput = () => { + let value = grupoInput.value; + value = value.toUpperCase().replace(/[^A-Z]/g, ''); + if (value.length> 1) value = value.charAt(0); + grupoInput.value = value; + }; + + /** + * Filtra el input de matrícula (solo números). + */ + const handleMatriculaInput = () => { + matriculaInput.value = matriculaInput.value.replace(/[^0-9]/g, ''); + }; + + /** + * Valida el email en tiempo real al perder el foco. + */ + const handleEmailBlur = () => { + const email = emailInput.value.trim(); + if (email && !validateEmail(email)) { + showError('emailError', 'Solo se permiten direcciones @gmail.com o @itsoeh.edu.mx'); + setAccessibilityError(emailInput, 'emailError'); + emailInput.style.borderColor = '#ef4444'; + } else { + clearError('emailError'); + clearAccessibilityError(emailInput); + emailInput.style.borderColor = '#333'; + } + }; + + /** + * Valida el usuario de GitHub en tiempo real (con debounce y AbortController). + * Cancela requests anteriores para evitar condiciones de carrera. + */ + const handleGitHubInput = () => { + // Cancelar request anterior si existe + if (githubAbortController) { + githubAbortController.abort(); + logDebug('🚫 Request anterior de GitHub cancelado'); + } + + clearTimeout(githubTimeout); + const username = githubInput.value.trim(); + + // Limpiar estilos y errores si el campo está vacío + if (username.length === 0) { + clearError('githubError'); + clearAccessibilityError(githubInput); + githubInput.style.borderColor = '#333'; + return; + } + + // Solo validar si tiene al menos 3 caracteres + if (username.length>= 3) { + githubInput.style.borderColor = '#666'; + clearError('githubError'); + clearAccessibilityError(githubInput); + + // Esperar 1.5 segundos después de que el usuario deje de escribir + githubTimeout = setTimeout(async () => { + // Crear nuevo AbortController para este request + githubAbortController = new AbortController(); + + const isValid = await validateGitHub(username, githubAbortController.signal); + + // Solo actualizar UI si el request no fue abortado + if (isValid !== null) { + if (!isValid) { + showError('githubError', 'Usuario de GitHub no encontrado'); + setAccessibilityError(githubInput, 'githubError'); + githubInput.style.borderColor = '#ef4444'; + } else { + clearError('githubError'); + clearAccessibilityError(githubInput); + githubInput.style.borderColor = '#22c55e'; + } + } + }, 1500); + } else { + githubInput.style.borderColor = '#333'; + clearError('githubError'); + clearAccessibilityError(githubInput); + } + }; + + /** + * Alterna la visibilidad de la contraseña en el input correspondiente. + * + * @param {MouseEvent} e - Evento click del botón de toggle. + */ + const togglePasswordVisibility = (e) => { + e.preventDefault(); + e.stopPropagation(); + + const btn = e.currentTarget; + const passwordInputContainer = btn.closest('.password-input'); + if (!passwordInputContainer) return; + + const input = passwordInputContainer.querySelector('input[type="password"], input[type="text"]'); + if (!input) return; + + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + + // Icono de ojo abierto (ver contraseña) + const eyeOpenIcon = ''; + + // Icono de ojo cerrado (ocultar contraseña) + const eyeClosedIcon = ''; + + btn.innerHTML = isPassword ? eyeClosedIcon : eyeOpenIcon; + }; + + // --- 3. ASIGNACIÓN DE EVENT LISTENERS --- + registerForm.addEventListener('submit', handleRegisterSubmit); + emailInput.addEventListener('blur', handleEmailBlur); + githubInput.addEventListener('input', handleGitHubInput); + if (matriculaInput) matriculaInput.addEventListener('input', handleMatriculaInput); + if (grupoInput) grupoInput.addEventListener('input', handleGrupoInput); + togglePwdBtns.forEach(btn => btn.addEventListener('click', togglePasswordVisibility)); + allFormInputs.forEach(input => { /* focus/blur */ }); + allFormInputs.forEach((input, index) => { /* Enter key */ }); + +}); // Fin de 'DOMContentLoaded' + + +// --- 4. FUNCIONES DE UTILIDAD (Puras) --- +/** + * Valida si un string es un correo electrónico de Gmail o institucional ITSOEH válido. + * + * @param {string} email - Email a validar. + * @returns {boolean} True si el email es válido, false si no. + */ +function validateEmail(email) { + const gmailRegex = /^[a-zA-Z0-9.+_-]+@gmail\.com$/; + const itsoehRegex = /^[a-zA-Z0-9.+_-]+@itsoeh\.edu\.mx$/; + const emailLowerCase = String(email).toLowerCase(); + return gmailRegex.test(emailLowerCase) || itsoehRegex.test(emailLowerCase); +} + +/** + * Valida si un usuario de GitHub existe usando la API pública de GitHub. + * Implementa cancelación de requests con AbortController. + * + * @param {string} username - Username de GitHub a validar. + * @param {AbortSignal} [signal] - Señal de AbortController para cancelar el request. + * @returns {Promise} True si el usuario existe, false si no, null si fue abortado. + */ +async function validateGitHub(username, signal = null) { + if (!username || username.length < 3) return false; + + try { + const fetchOptions = signal ? { signal } : {}; + const response = await fetch(`https://api.github.com/users/${username}`, fetchOptions); + + if (response.ok) { + logDebug('✅ Usuario de GitHub válido:', username); + return true; + } else { + logDebug('⚠️ Usuario de GitHub no encontrado:', username); + return false; + } + } catch (error) { + // Request fue abortado (no es un error real) + if (error.name === 'AbortError') { + logDebug('🚫 Request de GitHub abortado para:', username); + return null; + } + + logError('❌ Error validando GitHub:', error.message); + return false; + } +} + +/** + * Muestra un mensaje de error en el elemento especificado. + * + * @param {string} elementId - ID del elemento donde mostrar el error. + * @param {string} message - Mensaje de error. + */ +function showError(elementId, message) { + const el = document.getElementById(elementId); + if (el) { el.textContent = message; el.style.display = 'block'; } +} + +/** + * Limpia el mensaje de error del elemento especificado. + * + * @param {string} elementId - ID del elemento a limpiar. + */ +function clearError(elementId) { + const el = document.getElementById(elementId); + if (el) { el.textContent = ''; el.style.display = 'none'; } +} + +/** + * Muestra un mensaje de éxito o error en el formulario de registro. + * + * @param {'success'|'error'} type - Tipo de mensaje. + * @param {string} message - Mensaje a mostrar. + */ +function showMessage(type, message) { + const activeForm = document.getElementById('registerForm'); + if (!activeForm) return; + const existingMessage = activeForm.querySelector('.message.alert-message'); + if (existingMessage) existingMessage.remove(); + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${type} show alert-message`; + messageDiv.setAttribute('role', 'alert'); + messageDiv.setAttribute('aria-live', 'assertive'); + messageDiv.textContent = message; + activeForm.prepend(messageDiv); + + // Enfocar el mensaje para screen readers + messageDiv.setAttribute('tabindex', '-1'); + messageDiv.focus(); + + setTimeout(() => { + messageDiv.classList.remove('show'); + setTimeout(() => messageDiv.remove(), 500); + }, 5000); +} + +/** + * Configura atributos de accesibilidad para inputs con errores. + * + * @param {HTMLInputElement} input - Input element. + * @param {string|null} errorElementId - ID del elemento de error asociado. + */ +function setAccessibilityError(input, errorElementId) { + if (!input) return; + input.setAttribute('aria-invalid', 'true'); + if (errorElementId) { + input.setAttribute('aria-describedby', errorElementId); + } + input.focus(); +} + +/** + * Limpia atributos de accesibilidad de error en inputs. + * + * @param {HTMLInputElement} input - Input element. + */ +function clearAccessibilityError(input) { + if (!input) return; + input.setAttribute('aria-invalid', 'false'); + input.removeAttribute('aria-describedby'); +} + +/** + * Reserva identificadores únicos (githubUsername y matrícula) usando transacciones atómicas. + * Previene race conditions y garantiza unicidad en la base de datos. + * + * @param {string} uid - ID del usuario en Firebase Auth. + * @param {string} githubUsername - Username de GitHub a reservar. + * @param {string} matricula - Matrícula del estudiante a reservar. + * @param {object} userData - Datos completos del usuario a guardar. + * @returns {Promise} + * @throws {Error} Si el username o matrícula ya están en uso. + */ +async function reserveUniqueIdentifiers(uid, githubUsername, matricula, userData) { + try { + await runTransaction(db, async (transaction) => { + // Referencias a documentos de mapeo + const githubMappingRef = doc(db, 'github_usernames', githubUsername.toLowerCase()); + const matriculaMappingRef = doc(db, 'matriculas', matricula); + const userDocRef = doc(db, 'usuarios', uid); + + // 1. Verificar si el githubUsername ya existe + const githubDoc = await transaction.get(githubMappingRef); + if (githubDoc.exists()) { + throw new Error('GitHub username ya está en uso'); + } + + // 2. Verificar si la matrícula ya existe + const matriculaDoc = await transaction.get(matriculaMappingRef); + if (matriculaDoc.exists()) { + throw new Error('Matrícula ya está en uso'); + } + + // 3. Si ambos están disponibles, escribir en transacción atómica + + // Reservar githubUsername + transaction.set(githubMappingRef, { + uid: uid, + email: userData.email, + createdAt: new Date().toISOString() + }); + + // Reservar matrícula + transaction.set(matriculaMappingRef, { + uid: uid, + email: userData.email, + createdAt: new Date().toISOString() + }); + + // Escribir datos del usuario + transaction.set(userDocRef, { + ...userData, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + + // 4. SI ES ADMIN: Crear documento en colección 'admins' automáticamente + if (isAdminEmail(userData.email)) { + const adminDocRef = doc(db, 'admins', userData.email); + transaction.set(adminDocRef, { + email: userData.email, + uid: uid, + githubUsername: githubUsername, + matricula: matricula, + firstName: userData.firstName, + lastName: `${userData.apellidoPaterno || ''} ${userData.apellidoMaterno || ''}`.trim(), + role: 'admin', + createdAt: new Date().toISOString(), + permissions: DEFAULT_ADMIN_PERMISSIONS + }); + logDebug('✅ Usuario detectado como ADMIN - Agregado a colección admins'); + } + + logDebug('✅ Transacción completada: identificadores reservados y usuario creado'); + }); + + // Incrementar contador de usuarios en stats (después de la transacción) + incrementStat('totalUsers').catch(err => console.warn('⚠️ Stat update:', err)); + + } catch (error) { + // Propagar errores de transacción + if (error.message.includes('GitHub username ya está en uso')) { + throw new Error('GitHub username ya está en uso'); + } else if (error.message.includes('Matrícula ya está en uso')) { + throw new Error('Matrícula ya está en uso'); + } else { + logError('❌ Error en transacción de Firestore:', error); + throw new Error('No se pudieron guardar tus datos. Por favor, intenta de nuevo.'); + } + } +} \ No newline at end of file diff --git a/src/js/stats-updater.js b/src/js/stats-updater.js new file mode 100644 index 0000000..dd6c1fc --- /dev/null +++ b/src/js/stats-updater.js @@ -0,0 +1,200 @@ +// ========================================== +// STATS UPDATER - Sistema de stats agregados sin Cloud Functions +// ========================================== + +import { db } from './firebase-init.js'; +import { doc, getDoc, setDoc, updateDoc, increment, collection, getDocs, query, where } from 'https://www.gstatic.com/firebasejs/12.4.0/firebase-firestore.js'; + +// Configuración +const STATS_DOC_ID = 'general'; +const STATS_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutos +const STATS_LOCK_DURATION = 30 * 1000; // 30 segundos (lock para evitar actualizaciones simultáneas) + +/** + * Verifica si las stats necesitan actualizarse + */ +async function shouldUpdateStats() { + try { + const statsDoc = await getDoc(doc(db, 'stats', STATS_DOC_ID)); + + if (!statsDoc.exists()) { + return true; // Crear stats por primera vez + } + + const data = statsDoc.data(); + const lastUpdate = data.lastUpdate?.toMillis() || 0; + const now = Date.now(); + + // Si tiene lock y no ha expirado, no actualizar + if (data.updateLock && (now - data.updateLock) < STATS_LOCK_DURATION) { + console.log('⏳ Stats actualizándose por otro cliente...'); + return false; + } + + // Si pasaron más de 5 minutos, actualizar + return (now - lastUpdate)> STATS_UPDATE_INTERVAL; + + } catch (error) { + // Si no tiene permisos para leer, simplemente no actualizar + if (error.code === 'permission-denied') { + console.log('⚠️ Usuario sin permisos para actualizar stats'); + return false; + } + console.error('❌ Error verificando stats:', error); + return false; + } +} + +/** + * Calcula y actualiza las estadísticas agregadas + */ +async function updateAggregatedStats() { + try { + console.log('📊 Actualizando estadísticas agregadas...'); + + const statsRef = doc(db, 'stats', STATS_DOC_ID); + + // Poner lock temporal + await setDoc(statsRef, { + updateLock: Date.now() + }, { merge: true }); + + // Calcular stats en paralelo + const [usersSnapshot, exercisesSnapshot, submissionsSnapshot, resultsSnapshot] = await Promise.all([ + getDocs(collection(db, 'usuarios')), + getDocs(collection(db, 'exercises')), + getDocs(collection(db, 'submissions')), + getDocs(collection(db, 'results')) + ]); + + // Contar submissions exitosas + let successCount = 0; + resultsSnapshot.forEach(doc => { + if (doc.data().status === 'success') { + successCount++; + } + }); + + const successRate = resultsSnapshot.size> 0 + ? Math.round((successCount / resultsSnapshot.size) * 100) + : 0; + + // Guardar stats actualizadas + await setDoc(statsRef, { + totalUsers: usersSnapshot.size, + totalExercises: exercisesSnapshot.size, + totalSubmissions: submissionsSnapshot.size, + successCount: successCount, + totalResults: resultsSnapshot.size, + successRate: successRate, + lastUpdate: new Date(), + updateLock: null // Liberar lock + }); + + console.log('✅ Stats actualizadas:', { + users: usersSnapshot.size, + exercises: exercisesSnapshot.size, + submissions: submissionsSnapshot.size, + successRate: successRate + '%' + }); + + return true; + + } catch (error) { + console.error('❌ Error actualizando stats:', error); + + // Solo intentar liberar lock si el error NO es de permisos + if (error.code !== 'permission-denied') { + try { + await updateDoc(doc(db, 'stats', STATS_DOC_ID), { + updateLock: null + }); + } catch (e) { + // Silenciar error si es de permisos + if (e.code !== 'permission-denied') { + console.warn('⚠️ Error liberando lock:', e.message); + } + } + } + + return false; + } +} + +/** + * Intenta actualizar stats si es necesario + * Llamar esta función cuando un admin entre al panel + */ +export async function tryUpdateStats() { + try { + const shouldUpdate = await shouldUpdateStats(); + + if (shouldUpdate) { + // Actualizar en segundo plano (no bloquear UI) + updateAggregatedStats().catch(err => { + // Silenciar errores de permisos + if (err.code !== 'permission-denied') { + console.error('Error en actualización de stats:', err); + } + }); + } else { + console.log('📊 Stats actualizadas recientemente, usando caché'); + } + + } catch (error) { + // Silenciar errores de permisos + if (error.code !== 'permission-denied') { + console.error('❌ Error en tryUpdateStats:', error); + } + } +} + +/** + * Incrementa un contador específico de stats + * Llamar cuando se crea algo nuevo (usuario, ejercicio, submission) + */ +export async function incrementStat(statName, value = 1) { + try { + const statsRef = doc(db, 'stats', STATS_DOC_ID); + await updateDoc(statsRef, { + [statName]: increment(value), + lastUpdate: new Date() + }); + console.log(`✅ Stat incrementada: ${statName} +${value}`); + } catch (error) { + // Si no existe el documento, crearlo (solo admins pueden) + if (error.code === 'not-found') { + console.log('📊 Creando documento de stats por primera vez...'); + await updateAggregatedStats(); + } else if (error.code === 'permission-denied') { + // Usuario sin permisos, silenciar error (es esperado para usuarios normales) + console.log(`⚠️ Sin permisos para incrementar stat: ${statName}`); + } else { + console.error('❌ Error incrementando stat:', error); + } + } +} + +/** + * Inicializa el sistema de stats (crear documento si no existe) + * Solo debe ejecutarse para admins o en background sin bloquear + */ +export async function initializeStats() { + try { + const statsDoc = await getDoc(doc(db, 'stats', STATS_DOC_ID)); + + if (!statsDoc.exists()) { + console.log('📊 Documento de stats no existe, será creado cuando un admin entre al panel'); + // No intentar crear aquí, esperar a que un admin lo haga + return; + } + + } catch (error) { + // Silenciar errores de permisos (usuarios normales) + if (error.code === 'permission-denied') { + console.log('i️ Stats: Esperando inicialización por admin'); + } else { + console.warn('⚠️ Error verificando stats:', error.message); + } + } +} diff --git a/src/js/verify-email.js b/src/js/verify-email.js new file mode 100644 index 0000000..d7b818b --- /dev/null +++ b/src/js/verify-email.js @@ -0,0 +1,136 @@ +// src/js/verify-email.js +import { auth } from './firebase-init.js'; +import { onAuthStateChanged, sendEmailVerification, signOut } from "https://www.gstatic.com/firebasejs/12.4.0/firebase-auth.js"; + +/** + * @file verify-email.js + * Lógica para la verificación de correo electrónico y reenvío en la página de verificación. + */ + +// ========================================== +// LOGGING UTILITIES +// ========================================== +const isDevelopment = () => window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + +const logDebug = (...args) => { + if (isDevelopment()) console.log(...args); +}; + +const logWarn = (...args) => { + if (isDevelopment()) console.warn(...args); +}; + +const logError = (...args) => { + if (isDevelopment()) console.error(...args); +}; + +document.addEventListener('DOMContentLoaded', () => { + const userEmailElement = document.getElementById('userEmail'); + const resendEmailBtn = document.getElementById('resendEmailBtn'); + const verificationMessageElement = document.getElementById('verificationMessage'); + const loadingSpinner = document.getElementById('loadingSpinner'); + const logoutBtn = document.getElementById('logoutBtn'); + + let verificationCheckInterval; // Para guardar el intervalo de chequeo + + // Observador del estado de autenticación + onAuthStateChanged(auth, async (user) => { + if (user) { + // Usuario está logueado + logDebug('Usuario actual:', user.email, 'Verificado:', user.emailVerified); + userEmailElement.textContent = user.email; // Mostrar el email + + if (user.emailVerified) { + // Si YA está verificado, redirigir inmediatamente + logDebug('Correo ya verificado. Redirigiendo al dashboard...'); + clearInterval(verificationCheckInterval); // Detener chequeos + window.location.href = 'dashboard.html'; + } else { + // Si NO está verificado, empezar a chequear periódicamente + startVerificationCheck(user); + loadingSpinner.style.display = 'block'; // Mostrar spinner + } + } else { + // No hay usuario logueado, redirigir al login + logDebug('No hay usuario logueado. Redirigiendo a signin...'); + clearInterval(verificationCheckInterval); // Detener chequeos + window.location.href = 'signin.html'; + } + }); + + // Botón para reenviar correo + if (resendEmailBtn) { + resendEmailBtn.addEventListener('click', async () => { + const user = auth.currentUser; + if (user) { + try { + resendEmailBtn.disabled = true; // Deshabilitar mientras se envía + resendEmailBtn.textContent = 'Enviando...'; + await sendEmailVerification(user); + verificationMessageElement.textContent = `Se ha reenviado un correo a ${user.email}. Por favor, revisa tu bandeja de entrada.`; + logDebug('Correo de verificación reenviado.'); + // Rehabilitar después de un tiempo para evitar spam + setTimeout(() => { + resendEmailBtn.disabled = false; + resendEmailBtn.textContent = 'Reenviar Correo'; + }, 30000); // Esperar 30 segundos + } catch (error) { + logError("Error al reenviar correo:", error); + verificationMessageElement.textContent = "Error al reenviar el correo. Intenta de nuevo más tarde."; + resendEmailBtn.disabled = false; + resendEmailBtn.textContent = 'Reenviar Correo'; + } + } + }); + } + + // Botón de logout + if (logoutBtn) { + logoutBtn.addEventListener('click', (e) => { + e.preventDefault(); + signOut(auth).then(() => { + clearInterval(verificationCheckInterval); // Detener chequeos al salir + window.location.href = 'signin.html'; + }); + }); + } + + /** + * Inicia el chequeo periódico del estado de verificación del usuario. + * + * @param {import('firebase/auth').User} currentUser - Usuario actual de Firebase Auth. + */ + function startVerificationCheck(currentUser) { + // Limpiar intervalo anterior si existe + clearInterval(verificationCheckInterval); + + // Chequear cada 5 segundos + verificationCheckInterval = setInterval(async () => { + try { + // Recargar el estado del usuario desde Firebase + await currentUser.reload(); + const freshUser = auth.currentUser; // Obtener el estado más reciente + + logDebug('Chequeando verificación...', freshUser.emailVerified); + + if (freshUser && freshUser.emailVerified) { + // ¡Verificado! Detener el chequeo y redirigir + logDebug('¡Correo verificado! Redirigiendo al dashboard...'); + clearInterval(verificationCheckInterval); + loadingSpinner.style.display = 'none'; // Ocultar spinner + window.location.href = 'dashboard.html'; + } + // Si no está verificado, el intervalo continuará + } catch (error) { + logError("Error recargando estado del usuario:", error); + // Podría ser un error de red, no necesariamente detener el chequeo + } + }, 5000); // 5000 ms = 5 segundos + } + + // Limpiar intervalo si el usuario navega fuera de la página + window.addEventListener('beforeunload', () => { + clearInterval(verificationCheckInterval); + }); + +}); \ No newline at end of file diff --git a/src/main.css b/src/main.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.js b/src/main.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/admin.html b/src/pages/admin.html new file mode 100644 index 0000000..f71e736 --- /dev/null +++ b/src/pages/admin.html @@ -0,0 +1,278 @@ + + + + + + Panel de Administración - Java Tutor + + + + +
+ + + + + + + + + + +
+ +
+
+

Gestión de Ejercicios

+

Crea y administra ejercicios de Java

+
+
+ +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + + +
+
+ + +
+

Analíticas del Sistema

+
+
+
+ +
+
+
0
+
Usuarios Totales
+
+
+
+
+ +
+
+
0
+
Ejercicios Creados
+
+
+
+
+ +
+
+
0
+
Envíos Totales
+
+
+
+
+ +
+
+
0%
+
Tasa de Éxito
+
+
+
+
+
+
+ + +
+
+
+

Crear Nuevo Ejercicio

+ +
+ +
+
+ +
+

Información Básica

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

Código Base (Template)

+
+ + +
+
+ + +
+

Código de Test (AppTest.java)

+
+ +

Pega aquí el código completo de AppTest.java que validará las soluciones de los estudiantes.

+ +
+
+ + +
+

+ + Código de Solución (Solo Admins) +

+
+ +

+ + Este es el código que pasa todos los tests. Solo visible para administradores. +

+ +
+
+ + +
+ + +
+
+
+
+
+ + + + + + + + diff --git a/src/pages/dashboard.html b/src/pages/dashboard.html new file mode 100644 index 0000000..28876c0 --- /dev/null +++ b/src/pages/dashboard.html @@ -0,0 +1,281 @@ + + + + + + + + + Java Tutor - Dashboard + + + + + + + + + + +
+ + + + + +
+ + + + +
+

Reporte de Avances

+
+ +
+
+ + +
+ +
+ +
+
+

Tests Pasados

+
+ +
+
+
0
+
+ + + +12% + + vs último mes +
+
+ + +
+
+

Tests Fallados

+
+ +
+
+
0
+
+ + + -8% + + vs último mes +
+
+ + +
+
+

Tests Totales

+
+ +
+
+
0
+
+ Tests completados +
+
+ + +
+
+

Tasa de Éxito

+
+ +
+
+
0%
+
+ De todos los intentos +
+
+ + +
+
+

Puntos Totales

+
+ +
+
+
0
+
+ Puntos ganados +
+
+ + +
+
+

Progreso Total del Curso

+
+ +
+
+
0%
+
+ +
+
+ 0 de 50 ejercicios + completados +
+
+ + +
+
+

Envíos Recientes

+
+ +
+
+
+
+ +

No hay envíos recientes

+
+
+
+
+
+
+
+ + +
+
+ + +
+ Jose Manuel Cortes Ceron +

Jose Manuel Cortes Ceron

+

Desarrollador de Java Tutor

+

Datos de contacto para soporte

+
+ +
+
+

+ + Correo Electrónico +

+
+ deepdevjose@itsoeh.edu.mx + +
+
+ +
+

+ + WhatsApp +

+ + + Enviar mensaje + +
+ +
+

+ + GitHub +

+ + @deepdevjose + + +
+
+ +
+

+ + Made with ❤️ by DeepDevJose to ITICs - ITSOEH +

+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/pages/exercises.html b/src/pages/exercises.html new file mode 100644 index 0000000..0f09cc4 --- /dev/null +++ b/src/pages/exercises.html @@ -0,0 +1,373 @@ + + + + + + + Ejercicios - Java Tutor + + + + + + + + + + + + + + + +
+ + + + +
+ + + + +
+
+ + + + +
+ + +
+ +
+ +
+
+
+ + +
+

Ejercicios de Java

+

Mejora tus habilidades con ejercicios prácticos

+
+ + +
+
+
+ +
+
+ 0 + Completados +
+
+
+
+ +
+
+ 0 + Pendientes +
+
+
+
+ +
+
+ 0 + Puntos Ganados +
+
+
+
+ +
+
+ 0% + Progreso +
+
+
+ + +
+
+ +
+ + Filtros +
+ + +
+ +
+ +
+ + + + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + +
+
+
+ + + + + +
+ + +
+
+
+ + +
+ +
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+

Cargando...

+
+ + +
+
+ +
+ Cargando descripción... +
+ +
+
+ App.java + +
+ +
+ +
+ + +
+ + +
+

Resultados de la Validación

+ + +
+ + +
+ +

Validando tu código...

+
+
+
+ + +
+
+ + +
+ Jose Manuel Cortes Ceron +

Jose Manuel Cortes Ceron

+

Desarrollador de Java Tutor

+

Datos de contacto para soporte

+
+ +
+
+

+ + Correo Electrónico +

+
+ deepdevjose@itsoeh.edu.mx + +
+
+ +
+

+ + WhatsApp +

+ + + Enviar mensaje + +
+ +
+

+ + GitHub +

+ + @deepdevjose + + +
+
+ +
+

+ + Made with ❤️ by DeepDevJose to ITICs - ITSOEH +

+
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/pages/settings.html b/src/pages/settings.html new file mode 100644 index 0000000..6f12cf2 --- /dev/null +++ b/src/pages/settings.html @@ -0,0 +1,307 @@ + + + + + + + + + Java Tutor - Configuración + + + + + + + + + + + +
+ + + + + +
+ + + + +
+

Configuración

+
+ + +
+
+ + +
+
+
+ +
+
+

Información Personal

+

Actualiza tus datos personales y académicos

+
+
+ +
+
+
+ + + +
+ +
+ + + +
+
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ +
+
+
+ + +
+
+
+ +
+
+

Cuenta y Seguridad

+

Gestiona la seguridad de tu cuenta

+
+
+ +
+
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+
+
+ + +
+
+
+ +
+
+

Zona de Peligro

+

Acciones irreversibles con tu cuenta

+
+
+ +
+
+
+

Eliminar Cuenta

+

Una vez eliminada, no podrás recuperar tu cuenta ni tus datos. Esta acción es + permanente.

+
+ +
+
+
+ +
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/src/pages/signin.html b/src/pages/signin.html new file mode 100644 index 0000000..9c43aac --- /dev/null +++ b/src/pages/signin.html @@ -0,0 +1,116 @@ + + + + + + + + Java Tutor - Iniciar sesión + + + + + +
+ +
+
+
+ Java Tutor +
+

Inicia Sesión

+

Ingresa tus credenciales para acceder a tu cuenta.

+
+
+ + +
+
+

Que bueno es volver a verte!

+

Accede a tu cuenta

+ + +
+ +
+
+ + + +
+
+
+ +
+ + +
+
+ + + +
+ +
+ + +
+
+ + +
+ +

+ ¿No tienes una cuenta? Regístrate +

+
+
+
+
+
+ + + +
+
+ + +

Restablecer Contraseña

+

Ingresa tu correo electrónico y te enviaremos un enlace + para restablecer tu contraseña.

+ + +
+ + +
+ + + + + + +
+
+ + + + + + \ No newline at end of file diff --git a/src/pages/signup.html b/src/pages/signup.html new file mode 100644 index 0000000..cf91609 --- /dev/null +++ b/src/pages/signup.html @@ -0,0 +1,130 @@ + + + + + + + + Java Tutor - Registrarse + + + + + +
+ +
+
+
+ Java Tutor +
+

Registrate

+

Completa estos sencillos pasos para registrar
tu cuenta.

+
+
+ + +
+
+

Crear cuenta

+

Ingresa tus datos personales para crear tu cuenta.

+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ +
+ + +
+ Debe tener al menos 6 caracteres. +
+ + + + + +

+ ¿Ya tienes una cuenta? Inicia sesión +

+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/pages/verify-email.html b/src/pages/verify-email.html new file mode 100644 index 0000000..70d7945 --- /dev/null +++ b/src/pages/verify-email.html @@ -0,0 +1,64 @@ + + + + + + + + Verifica tu Correo - Java Tutor + + + + + + +
+ +
+
+
+ Java Tutor +
+

¡Casi Listo!

+

Solo un paso más para activar tu cuenta.

+
+
+ + +
+
+
+ + + + + +
+

Verifica tu Correo Electrónico

+

+ Hemos enviado un enlace de verificación a tu correo. Por favor, + revisa tu bandeja de entrada (y la carpeta de spam) y haz clic en el enlace para activar tu cuenta. +

+ + +
+ + Cerrar sesión e ir a Login +
+ +

+ Una vez verificado, serás redirigido automáticamente al dashboard. +

+ + +
+
+
+ + + + + + + \ No newline at end of file