commit d31575a14350e6762cb7d09f7e8b5ce83a868355 Author: Renato Date: Thu Feb 12 01:30:57 2026 +0100 Initial commit: Plataforma de Economía Features: - React 18 + TypeScript frontend with Vite - Go + Gin backend API - PostgreSQL database - JWT authentication with refresh tokens - User management (admin panel) - Docker containerization - Progress tracking system - 4 economic modules structure Fixed: - Login with username or email - User creation without required email - Database nullable timestamps - API response field naming diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..378ec77 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Database +DB_USER=econ_user +DB_PASSWORD=change_this_password_in_production +DB_NAME=econ_db +DB_HOST=localhost +DB_PORT=5432 + +# JWT +JWT_SECRET=your-secret-key-change-in-production-min-32-chars + +# Server +SERVER_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4cd1ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +frontend/node_modules/ +backend/vendor/ + +# Build outputs +frontend/dist/ +backend/server + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Test coverage +coverage/ +*.cover + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..18fffd6 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Plataforma de Aprendizaje de Economía + +## 📚 Descripción +Plataforma web interactiva para enseñar economía a través de 4 módulos basados en material académico PDF. + +## 🎯 Objetivo +Crear una experiencia de aprendizaje gamificada con ejercicios interactivos, visualizaciones dinámicas y seguimiento de progreso. + +## 📖 Módulos Educativos + +### Módulo 1: Fundamentos de Economía +- **Temas**: Definición de economía, agentes económicos, factores de producción, flujo circular +- **Ejercicios**: Simulador de disyuntivas, Quiz de clasificación de bienes, Juego del flujo circular + +### Módulo 2: Oferta, Demanda y Equilibrio +- **Temas**: Curvas de oferta/demanda, equilibrio de mercado, controles de precios +- **Ejercicios**: Constructor de curvas interactivo, Simulador de precios máximos/mínimos + +### Módulo 3: Utilidad y Elasticidad +- **Temas**: Utilidad marginal, elasticidades, clasificación de bienes +- **Ejercicios**: Calculadora de elasticidad, Ejercicios tipo examen, Clasificador de bienes + +### Módulo 4: Teoría del Productor +- **Temas**: Costos, producción, competencia perfecta, maximización de beneficios +- **Ejercicios**: Simulador de decisión de producción, Calculadora de costos + +## 🏗️ Arquitectura Técnica + +### Stack Tecnológico +- **Frontend**: React 18 + TypeScript + Tailwind CSS +- **Visualización**: D3.js + Recharts +- **Estado**: Zustand +- **Routing**: React Router v6 +- **Build**: Vite +- **Container**: Docker + Docker Compose + +### Estructura de Carpetas +``` +econ-learning/ +├── src/ +│ ├── components/ # Componentes reutilizables +│ │ ├── charts/ # Gráficos interactivos +│ │ ├── exercises/ # Ejercicios específicos +│ │ └── ui/ # Componentes UI base +│ ├── modules/ # Módulos educativos +│ │ ├── clase1/ +│ │ ├── clase2/ +│ │ ├── clase3/ +│ │ └── clase4/ +│ ├── hooks/ # Custom hooks +│ ├── stores/ # Estado global +│ └── utils/ # Utilidades +├── public/ # Assets estáticos +├── docker/ # Configuración Docker +└── docs/ # Documentación técnica +``` + +## 🚀 Instrucciones de Despliegue + +### Desarrollo Local +```bash +# Instalar dependencias +npm install + +# Iniciar servidor de desarrollo +npm run dev +``` + +### Producción con Docker +```bash +# Construir imagen +docker-compose up -d + +# Ver logs +docker-compose logs -f +``` + +## 📝 Roadmap + +### Fase 1: Fundamentos (Semana 1-2) +- [ ] Setup del proyecto con Vite + React + TS +- [ ] Configuración de Docker +- [ ] Componentes base UI +- [ ] Estructura de routing + +### Fase 2: Módulo 1 (Semana 3) +- [ ] Contenido teórico del Módulo 1 +- [ ] Simulador de disyuntivas +- [ ] Quiz de clasificación de bienes +- [ ] Juego del flujo circular + +### Fase 3: Módulo 2 (Semana 4) +- [ ] Constructor de curvas interactivo +- [ ] Simulador de precios +- [ ] Ejercicios de equilibrio + +### Fase 4: Módulos 3-4 (Semana 5-6) +- [ ] Calculadora de elasticidad +- [ ] Simulador de costos +- [ ] Sistema de puntuación + +### Fase 5: Pulido (Semana 7) +- [ ] Tests +- [ ] Optimización de rendimiento +- [ ] Documentación final + +## 🔧 Requisitos del Sistema +- Node.js 18+ +- Docker (opcional) +- Navegador moderno con soporte ES6+ + +## 📄 Licencia +Proyecto educativo personal. diff --git a/TECH_SPECS.md b/TECH_SPECS.md new file mode 100644 index 0000000..058f289 --- /dev/null +++ b/TECH_SPECS.md @@ -0,0 +1,168 @@ +# Especificaciones Técnicas - Plataforma Economía + +## 1. Arquitectura Frontend + +### Tecnologías Principales +- **Framework**: React 18.2+ +- **Lenguaje**: TypeScript 5.0+ +- **Styling**: Tailwind CSS 3.4+ +- **Build Tool**: Vite 5.0+ +- **Package Manager**: npm + +### Dependencias Clave +```json +{ + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "d3": "^7.8.0", + "recharts": "^2.10.0", + "zustand": "^4.4.0", + "lucide-react": "^0.294.0", + "framer-motion": "^10.16.0" + } +} +``` + +## 2. Componentes Interactivos Planificados + +### 2.1 GraficoCurva (Módulos 2, 3, 4) +```typescript +interface GraficoCurvaProps { + tipo: 'oferta' | 'demanda' | 'equilibrio' | 'costos'; + datos: Punto[]; + interactivo: boolean; + onPuntoClick?: (punto: Punto) => void; +} +``` + +### 2.2 SimuladorPrecios (Módulo 2) +- Sliders para ajustar curvas +- Visualización de excedentes +- Animaciones de transición + +### 2.3 CalculadoraElasticidad (Módulo 3) +- Inputs para valores Q1, Q2, P1, P2 +- Cálculo automático con fórmula +- Visualización del resultado + +### 2.4 JuegoFlujoCircular (Módulo 1) +- Drag & drop de elementos +- Conexiones entre agentes económicos +- Validación de respuestas + +## 3. Estructura de Estado + +```typescript +interface AppState { + progreso: { + modulo1: { completado: number; ejercicios: boolean[] }; + modulo2: { completado: number; ejercicios: boolean[] }; + modulo3: { completado: number; ejercicios: boolean[] }; + modulo4: { completado: number; ejercicios: boolean[] }; + }; + usuario: { + nombre: string; + puntuacion: number; + logros: string[]; + }; + preferencias: { + modoOscuro: boolean; + notificaciones: boolean; + }; +} +``` + +## 4. Configuración Docker + +### Dockerfile +```dockerfile +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +### docker-compose.yml +```yaml +version: '3.8' +services: + econ-learning: + build: . + ports: + - "3000:80" + restart: unless-stopped +``` + +## 5. Rutas de la Aplicación + +``` +/ → Landing page +/modulos → Lista de módulos +/modulo/1 → Módulo 1: Fundamentos +/modulo/2 → Módulo 2: Oferta/Demanda +/modulo/3 → Módulo 3: Elasticidad +/modulo/4 → Módulo 4: Productor +/ejercicios/:id → Ejercicio específico +/progreso → Dashboard de progreso +``` + +## 6. Diseño UI/UX + +### Paleta de Colores +- Primary: #2563eb (Azul) +- Secondary: #7c3aed (Violeta) +- Success: #10b981 (Verde) +- Warning: #f59e0b (Naranja) +- Error: #ef4444 (Rojo) +- Background: #f8fafc (Gris claro) +- Surface: #ffffff (Blanco) + +### Tipografía +- **Headings**: Inter, 600-700 weight +- **Body**: Inter, 400 weight +- **Monospace**: JetBrains Mono (para fórmulas) + +### Breakpoints +- Mobile: < 640px +- Tablet: 640px - 1024px +- Desktop: > 1024px + +## 7. Optimizaciones Planificadas + +### Performance +- Lazy loading de módulos +- Code splitting por ruta +- Virtualización de listas largas +- Caché de assets con service worker + +### Accesibilidad +- ARIA labels en elementos interactivos +- Soporte para navegación por teclado +- Contraste WCAG AA +- Screen reader compatible + +## 8. Testing Strategy + +### Unit Tests (Vitest) +- Lógica de cálculos económicos +- Hooks personalizados +- Utilidades + +### Integration Tests (React Testing Library) +- Flujo de navegación +- Interacción con gráficos +- Formularios + +### E2E Tests (Playwright) +- Rutas críticas +- Ejercicios completos +- Persistencia de datos diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..764d4ba --- /dev/null +++ b/TODO.md @@ -0,0 +1,117 @@ +# Plan de Desarrollo - Plataforma Economía + +## ✅ Completado +- [x] Extracción de contenido de los 4 PDFs +- [x] Análisis de temas y ejercicios potenciales +- [x] Creación de carpeta econ en VPS +- [x] Subida de PDFs al servidor +- [x] Documentación inicial del proyecto + +## 📋 Tareas Pendientes + +### Fase 1: Setup Inicial +- [ ] Inicializar proyecto Vite + React + TypeScript +- [ ] Configurar Tailwind CSS +- [ ] Instalar dependencias principales +- [ ] Configurar estructura de carpetas +- [ ] Setup de ESLint + Prettier +- [ ] Crear Dockerfile +- [ ] Crear docker-compose.yml +- [ ] Configurar Nginx + +### Fase 2: Componentes Base +- [ ] Crear Layout principal con navegación +- [ ] Componente Button con variantes +- [ ] Componente Card para contenido +- [ ] Sistema de progreso/visualización +- [ ] Tema claro/oscuro +- [ ] Componente Quiz base +- [ ] Componente de feedback (correcto/incorrecto) + +### Fase 3: Módulo 1 - Fundamentos +- [ ] Página de introducción +- [ ] Contenido: Definición de economía +- [ ] Contenido: Agentes económicos +- [ ] Contenido: Factores de producción +- [ ] Contenido: Flujo circular +- [ ] Ejercicio 1: Simulador de disyuntivas +- [ ] Ejercicio 2: Quiz de bienes (normal/inferior/etc) +- [ ] Ejercicio 3: Juego del flujo circular (drag & drop) +- [ ] Test del módulo + +### Fase 4: Módulo 2 - Oferta/Demanda +- [ ] Página de introducción +- [ ] Contenido: Ley de la demanda +- [ ] Contenido: Ley de la oferta +- [ ] Contenido: Equilibrio de mercado +- [ ] Contenido: Precios máximos y mínimos +- [ ] Ejercicio 1: Constructor de curvas +- [ ] Ejercicio 2: Simulador de precios intervenidos +- [ ] Ejercicio 3: Identificar shocks (¿qué curva se mueve?) +- [ ] Test del módulo + +### Fase 5: Módulo 3 - Elasticidad +- [ ] Página de introducción +- [ ] Contenido: Tipos de elasticidad +- [ ] Contenido: Fórmulas y cálculos +- [ ] Ejercicio 1: Calculadora de elasticidad paso a paso +- [ ] Ejercicio 2: Clasificar bienes según elasticidad +- [ ] Ejercicio 3: Ejercicios tipo examen +- [ ] Test del módulo + +### Fase 6: Módulo 4 - Productor +- [ ] Página de introducción +- [ ] Contenido: Costos y producción +- [ ] Contenido: Competencia perfecta +- [ ] Ejercicio 1: Simulador de decisión de producción +- [ ] Ejercicio 2: Calculadora de costos +- [ ] Ejercicio 3: Visualización de excedentes +- [ ] Test del módulo + +### Fase 7: Gamificación +- [ ] Sistema de puntuación +- [ ] Logros/badges +- [ ] Barra de progreso global +- [ ] Dashboard de estadísticas +- [ ] Ranking (opcional) + +### Fase 8: Testing y Optimización +- [ ] Tests unitarios (Vitest) +- [ ] Tests de integración +- [ ] Tests E2E con Playwright +- [ ] Optimización de imágenes/assets +- [ ] Performance audit (Lighthouse) +- [ ] Accesibilidad audit + +### Fase 9: Deploy +- [ ] Configurar dominio (si aplica) +- [ ] SSL/TLS +- [ ] CI/CD pipeline +- [ ] Backup automático +- [ ] Monitoreo + +## 🎯 Prioridades + +### Alta Prioridad +1. Setup inicial y estructura base +2. Módulo 1 completamente funcional +3. Componentes de gráficos interactivos +4. Docker funcionando + +### Media Prioridad +5. Módulos 2, 3 y 4 +6. Sistema de progreso +7. Tests básicos + +### Baja Prioridad +8. Gamificación avanzada +9. Tests E2E completos +10. Optimizaciones finales + +## 📝 Notas + +- Usar D3.js para gráficos complejos (curvas personalizables) +- Usar Recharts para gráficos simples (barras, líneas) +- Framer Motion para animaciones suaves +- Zustand para estado global simple +- LocalStorage para persistencia de progreso (fase 1) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e0e82d3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server + +# Final stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk add --no-cache ca-certificates + +# Copy binary from builder +COPY --from=builder /server . + +# Expose port +EXPOSE 8080 + +# Run the binary +CMD ["./server"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..184fa6b --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,259 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" + + "github.com/ren/econ/backend/internal/config" + "github.com/ren/econ/backend/internal/handlers" + "github.com/ren/econ/backend/internal/middleware" + "github.com/ren/econ/backend/internal/repository" + "github.com/ren/econ/backend/internal/services" +) + +func main() { + // Load .env file if exists + _ = godotenv.Load() + + cfg := config.Load() + + // Connect to PostgreSQL + connStr := fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=disable", + cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName, + ) + + ctx := context.Background() + dbPool, err := pgxpool.New(ctx, connStr) + if err != nil { + log.Fatalf("Error connecting to database: %v", err) + } + defer dbPool.Close() + + // Test connection + if err := dbPool.Ping(ctx); err != nil { + log.Fatalf("Error pinging database: %v", err) + } + log.Println("Connected to PostgreSQL") + + // Run migrations + runMigrations(ctx, dbPool) + + // Initialize repositories + userRepo := repository.NewUserRepository(dbPool) + progresoRepo := repository.NewProgresoRepository(dbPool) + contenidoRepo := repository.NewContenidoRepository(dbPool) + + // Initialize services + authService := services.NewAuthService(cfg, userRepo) + + // Initialize handlers + authHandler := handlers.NewAuthHandler(authService) + usersHandler := handlers.NewUsersHandler(userRepo, progresoRepo, authService) + progresoHandler := handlers.NewProgresoHandler(progresoRepo) + contenidoHandler := handlers.NewContenidoHandler(contenidoRepo) + + // Setup Gin router + router := gin.Default() + + // CORS middleware + router.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + }) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // API routes + api := router.Group("/api") + { + // Auth routes (public) + auth := api.Group("/auth") + { + auth.POST("/login", authHandler.Login) + auth.POST("/refresh", authHandler.RefreshToken) + } + + // Protected routes + protected := api.Group("") + protected.Use(middleware.AuthMiddleware(authService)) + { + // Auth logout + auth.POST("/auth/logout", authHandler.Logout) + + // Progreso routes + progreso := protected.Group("/progreso") + { + progreso.GET("", progresoHandler.GetProgreso) + progreso.GET("/modulo/:numero", progresoHandler.GetProgresoModulo) + progreso.PUT("/:ejercicioId", progresoHandler.UpdateProgreso) + progreso.GET("/resumen", progresoHandler.GetResumen) + } + + // Contenido routes + contenido := protected.Group("/contenido") + { + contenido.GET("/modulos", contenidoHandler.GetModulos) + contenido.GET("/modulos/:numero", contenidoHandler.GetModulo) + } + + // Admin routes (requires admin role) + admin := protected.Group("/admin") + admin.Use(middleware.AdminMiddleware()) + { + admin.GET("/usuarios", usersHandler.ListUsers) + admin.POST("/usuarios", usersHandler.CreateUser) + admin.GET("/usuarios/:id", usersHandler.GetUser) + admin.PUT("/usuarios/:id", usersHandler.UpdateUser) + admin.DELETE("/usuarios/:id", usersHandler.DeleteUser) + admin.GET("/usuarios/:id/progreso", usersHandler.GetUserProgreso) + } + } + } + + // Start server + port := getEnv("PORT", "8080") + log.Printf("Server starting on port %s", port) + if err := router.Run(":" + port); err != nil { + log.Fatalf("Error starting server: %v", err) + } +} + +func runMigrations(ctx context.Context, dbPool *pgxpool.Pool) { + // Inline migrations + migrations := []string{ + `CREATE TABLE IF NOT EXISTS usuarios ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + nombre VARCHAR(100) NOT NULL, + rol VARCHAR(20) DEFAULT 'estudiante' CHECK (rol IN ('admin', 'estudiante')), + creado_en TIMESTAMP DEFAULT NOW(), + ultimo_login TIMESTAMP, + activo BOOLEAN DEFAULT TRUE + )`, + `CREATE TABLE IF NOT EXISTS progreso_usuario ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE, + modulo_numero INTEGER NOT NULL CHECK (modulo_numero BETWEEN 1 AND 4), + ejercicio_id VARCHAR(50) NOT NULL, + completado BOOLEAN DEFAULT FALSE, + puntuacion INTEGER DEFAULT 0, + intentos INTEGER DEFAULT 0, + ultima_vez TIMESTAMP DEFAULT NOW(), + respuesta_json JSONB, + UNIQUE(usuario_id, modulo_numero, ejercicio_id) + )`, + `CREATE TABLE IF NOT EXISTS ejercicios ( + id VARCHAR(50) PRIMARY KEY, + modulo_numero INTEGER NOT NULL, + titulo VARCHAR(200) NOT NULL, + tipo VARCHAR(50) NOT NULL, + contenido JSONB NOT NULL, + orden INTEGER DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + expira TIMESTAMP NOT NULL, + creado_en TIMESTAMP DEFAULT NOW() + )`, + } + + for _, m := range migrations { + if _, err := dbPool.Exec(ctx, m); err != nil { + log.Printf("Migration warning: %v", err) + } + } + + // Seed ejercicios if empty + var count int + dbPool.QueryRow(ctx, "SELECT COUNT(*) FROM ejercicios").Scan(&count) + if count == 0 { + seedEjercicios(ctx, dbPool) + } + + // Create admin user if not exists + var adminCount int + dbPool.QueryRow(ctx, "SELECT COUNT(*) FROM usuarios WHERE rol = 'admin'").Scan(&adminCount) + if adminCount == 0 { + // Default admin: renato97 / wlillidan1 (bcrypt hash) + _, err := dbPool.Exec(ctx, ` + INSERT INTO usuarios (email, username, password_hash, nombre, rol) + VALUES ('renato97@econ.local', 'renato97', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.rsW4WzOFbMB3dHI.Bu', 'Renato', 'admin') + `) + if err != nil { + log.Printf("Warning: could not create admin user: %v", err) + } else { + log.Println("Admin user created: renato97 / wlillidan1") + } + } + + log.Println("Migrations completed successfully") +} + +func seedEjercicios(ctx context.Context, pool *pgxpool.Pool) { + ejercicios := []struct { + ID string + ModuloNumero int + Titulo string + Tipo string + Contenido string + Orden int + }{ + // Módulo 1 + {"m1e1", 1, "Simulador de Disyuntivas", "interactivo", `{"tipo":"slider","descripcion":"Elige cuanto producir de cada bien"}`, 1}, + {"m1e2", 1, "Clasificación de Bienes", "quiz", `{"preguntas":[{"id":"q1","pregunta":"La leche es un bien...","opciones":["normal","inferior","de lujo"],"respuesta":"normal"}]}`, 2}, + {"m1e3", 1, "Flujo Circular", "dragdrop", `{"agentes":["familias","empresas"]}`, 3}, + // Módulo 2 + {"m2e1", 2, "Constructor de Curvas", "interactivo", `{"curvas":["oferta","demanda"]}`, 1}, + {"m2e2", 2, "Precios Máximos y Mínimos", "simulador", `{"tipo_precio":"seleccionar"}`, 2}, + {"m2e3", 2, "Shocks de Mercado", "quiz", `{"escenarios":[{"id":"s1","evento":"Sequía","pregunta":"¿Qué curva se ve afectada?","respuesta":"oferta"}]}`, 3}, + // Módulo 3 + {"m3e1", 3, "Calculadora de Elasticidad", "calculadora", `{"formula":"elasticidad"}`, 1}, + {"m3e2", 3, "Clasificador de Bienes", "quiz", `{"bienes":[{"nombre":"Pan","elasticidad":"inelastico"}]}`, 2}, + {"m3e3", 3, "Ejercicios tipo Examen", "quiz", `{"preguntas":[]}`, 3}, + // Módulo 4 + {"m4e1", 4, "Simulador de Producción", "simulador", `{"max_produccion":100}`, 1}, + {"m4e2", 4, "Calculadora de Costos", "calculadora", `{"tipos":["fijo","variable"]}`, 2}, + {"m4e3", 4, "Excedentes", "interactivo", `{"excedentes":["productor","consumidor"]}`, 3}, + } + + for _, e := range ejercicios { + _, err := pool.Exec(ctx, ` + INSERT INTO ejercicios (id, modulo_numero, titulo, tipo, contenido, orden) + VALUES ($1, $2, $3, $4, $5, $6) + `, e.ID, e.ModuloNumero, e.Titulo, e.Tipo, e.Contenido, e.Orden) + if err != nil { + log.Printf("Warning: could not seed ejercicio %s: %v", e.ID, err) + } + } + log.Println("Ejercicios seeded") +} + +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c79b7c4 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,44 @@ +module github.com/ren/econ/backend + +go 1.23 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/google/uuid v1.5.0 + github.com/jackc/pgx/v5 v5.5.2 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.18.0 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..204e968 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,110 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA= +github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1b34a2e --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + + JWTSecret string + JWTExpirationHours int + RefreshExpDays int +} + +func Load() *Config { + return &Config{ + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnvAsInt("DB_PORT", 5432), + DBUser: getEnv("DB_USER", "econ_user"), + DBPassword: getEnv("DB_PASSWORD", "econ_pass"), + DBName: getEnv("DB_NAME", "econ_db"), + JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"), + JWTExpirationHours: 15 * 60, // 15 minutes in minutes (900 minutes = 15 hours) + RefreshExpDays: 7, + } +} + +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int) int { + if value, exists := os.LookupEnv(key); exists { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go new file mode 100644 index 0000000..dc7bc5e --- /dev/null +++ b/backend/internal/handlers/auth.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/ren/econ/backend/internal/models" + "github.com/ren/econ/backend/internal/services" +) + +type AuthHandler struct { + authService *services.AuthService +} + +func NewAuthHandler(authService *services.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +// Login godoc +// @Summary Iniciar sesión +// @Description Autentica usuario y devuelve tokens JWT +// @Tags auth +// @Accept json +// @Produce json +// @Param login body models.LoginRequest true "Credenciales" +// @Success 200 {object} models.LoginResponse +// @Failure 401 {object} map[string]string +// @Router /api/auth/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.authService.Login(c.Request.Context(), &req) + if err != nil { + switch err { + case services.ErrInvalidCredentials: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Credenciales inválidas"}) + case services.ErrUserInactive: + c.JSON(http.StatusForbidden, gin.H{"error": "Usuario inactivo"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error interno"}) + } + return + } + + c.JSON(http.StatusOK, resp) +} + +// RefreshToken godoc +// @Summary Renovar token de acceso +// @Description Renueva el token de acceso usando el refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param refresh body models.RefreshRequest true "Refresh token" +// @Success 200 {object} models.LoginResponse +// @Failure 401 {object} map[string]string +// @Router /api/auth/refresh [post] +func (h *AuthHandler) RefreshToken(c *gin.Context) { + var req models.RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken) + if err != nil { + switch err { + case services.ErrInvalidToken, services.ErrTokenExpired: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Token inválido o expirado"}) + case services.ErrUserNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"}) + case services.ErrUserInactive: + c.JSON(http.StatusForbidden, gin.H{"error": "Usuario inactivo"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error interno"}) + } + return + } + + c.JSON(http.StatusOK, resp) +} + +// Logout godoc +// @Summary Cerrar sesión +// @Description Cierra la sesión del usuario +// @Tags auth +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]string +// @Router /api/auth/logout [post] +func (h *AuthHandler) Logout(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"}) + return + } + + err := h.authService.Logout(c.Request.Context(), userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al cerrar sesión"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Sesión cerrada exitosamente"}) +} diff --git a/backend/internal/handlers/contenido.go b/backend/internal/handlers/contenido.go new file mode 100644 index 0000000..f247840 --- /dev/null +++ b/backend/internal/handlers/contenido.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/ren/econ/backend/internal/models" + "github.com/ren/econ/backend/internal/repository" +) + +type ContenidoHandler struct { + contenidoRepo *repository.ContenidoRepository +} + +func NewContenidoHandler(contenidoRepo *repository.ContenidoRepository) *ContenidoHandler { + return &ContenidoHandler{contenidoRepo: contenidoRepo} +} + +// GetModulos godoc +// @Summary Listar módulos +// @Description Lista todos los módulos disponibles +// @Tags contenido +// @Produce json +// @Security BearerAuth +// @Success 200 {array} models.ModuloResumen +// @Router /api/contenido/modulos [get] +func (h *ContenidoHandler) GetModulos(c *gin.Context) { + modulos, err := h.contenidoRepo.GetModulos(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener módulos"}) + return + } + + if modulos == nil { + modulos = []models.ModuloResumen{} + } + + c.JSON(http.StatusOK, modulos) +} + +// GetModulo godoc +// @Summary Obtener contenido de módulo +// @Description Obtiene el contenido de un módulo específico +// @Tags contenido +// @Produce json +// @Security BearerAuth +// @Param numero path int true "Número del módulo" +// @Success 200 {object} models.Modulo +// @Router /api/contenido/modulos/{numero} [get] +func (h *ContenidoHandler) GetModulo(c *gin.Context) { + moduloNumero, err := strconv.Atoi(c.Param("numero")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Número de módulo inválido"}) + return + } + + modulo, err := h.contenidoRepo.GetModulo(c.Request.Context(), moduloNumero) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener módulo"}) + return + } + + if modulo == nil || len(modulo.Ejercicios) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Módulo no encontrado"}) + return + } + + c.JSON(http.StatusOK, modulo) +} diff --git a/backend/internal/handlers/progreso.go b/backend/internal/handlers/progreso.go new file mode 100644 index 0000000..6470adb --- /dev/null +++ b/backend/internal/handlers/progreso.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/ren/econ/backend/internal/models" + "github.com/ren/econ/backend/internal/repository" +) + +type ProgresoHandler struct { + progresoRepo *repository.ProgresoRepository +} + +func NewProgresoHandler(progresoRepo *repository.ProgresoRepository) *ProgresoHandler { + return &ProgresoHandler{progresoRepo: progresoRepo} +} + +// GetProgreso godoc +// @Summary Obtener todo el progreso +// @Description Obtiene todo el progreso del usuario autenticado +// @Tags progreso +// @Produce json +// @Security BearerAuth +// @Success 200 {array} models.Progreso +// @Router /api/progreso [get] +func (h *ProgresoHandler) GetProgreso(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"}) + return + } + + progresos, err := h.progresoRepo.GetByUsuario(c.Request.Context(), userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"}) + return + } + + if progresos == nil { + progresos = []models.Progreso{} + } + + c.JSON(http.StatusOK, progresos) +} + +// GetProgresoModulo godoc +// @Summary Obtener progreso por módulo +// @Description Obtiene el progreso del usuario en un módulo específico +// @Tags progreso +// @Produce json +// @Security BearerAuth +// @Param numero path int true "Número del módulo" +// @Success 200 {array} models.Progreso +// @Router /api/progreso/modulo/{numero} [get] +func (h *ProgresoHandler) GetProgresoModulo(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"}) + return + } + + moduloNumero, err := strconv.Atoi(c.Param("numero")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Número de módulo inválido"}) + return + } + + progresos, err := h.progresoRepo.GetByModulo(c.Request.Context(), userID.(uuid.UUID), moduloNumero) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"}) + return + } + + if progresos == nil { + progresos = []models.Progreso{} + } + + c.JSON(http.StatusOK, progresos) +} + +// UpdateProgreso godoc +// @Summary Guardar avance +// @Description Guarda el progreso de un ejercicio +// @Tags progreso +// @Accept json +// @Produce json +// @Param ejercicioId path int true "ID del ejercicio" +// @Param progreso body models.ProgresoUpdate true "Datos del progreso" +// @Security BearerAuth +// @Success 200 {object} map[string]string +// @Router /api/progreso/{ejercicioId} [put] +func (h *ProgresoHandler) UpdateProgreso(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"}) + return + } + + ejercicioID, err := strconv.Atoi(c.Param("ejercicioId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de ejercicio inválido"}) + return + } + + var req models.ProgresoUpdate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.progresoRepo.Upsert(c.Request.Context(), userID.(uuid.UUID), ejercicioID, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al guardar progreso: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Progreso guardado exitosamente"}) +} + +// GetResumen godoc +// @Summary Obtener resumen +// @Description Obtiene estadísticas del progreso del usuario +// @Tags progreso +// @Produce json +// @Security BearerAuth +// @Success 200 {object} models.ProgresoResumen +// @Router /api/progreso/resumen [get] +func (h *ProgresoHandler) GetResumen(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"}) + return + } + + resumen, err := h.progresoRepo.GetResumen(c.Request.Context(), userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener resumen"}) + return + } + + c.JSON(http.StatusOK, resumen) +} diff --git a/backend/internal/handlers/users.go b/backend/internal/handlers/users.go new file mode 100644 index 0000000..7e108e2 --- /dev/null +++ b/backend/internal/handlers/users.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/ren/econ/backend/internal/models" + "github.com/ren/econ/backend/internal/repository" + "github.com/ren/econ/backend/internal/services" +) + +type UsersHandler struct { + userRepo *repository.UserRepository + progresoRepo *repository.ProgresoRepository + authService *services.AuthService +} + +func NewUsersHandler(userRepo *repository.UserRepository, progresoRepo *repository.ProgresoRepository, authService *services.AuthService) *UsersHandler { + return &UsersHandler{ + userRepo: userRepo, + progresoRepo: progresoRepo, + authService: authService, + } +} + +// ListUsers godoc +// @Summary Listar usuarios +// @Description Lista todos los usuarios (solo admin) +// @Tags admin +// @Produce json +// @Security BearerAuth +// @Success 200 {array} models.Usuario +// @Router /api/admin/usuarios [get] +func (h *UsersHandler) ListUsers(c *gin.Context) { + users, err := h.userRepo.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al listar usuarios"}) + return + } + + if users == nil { + users = []models.Usuario{} + } + + c.JSON(http.StatusOK, users) +} + +// CreateUser godoc +// @Summary Crear usuario +// @Description Crea un nuevo usuario (solo admin) +// @Tags admin +// @Accept json +// @Produce json +// @Param usuario body models.UsuarioCreate true "Usuario a crear" +// @Security BearerAuth +// @Success 201 {object} models.Usuario +// @Router /api/admin/usuarios [post] +func (h *UsersHandler) CreateUser(c *gin.Context) { + var req models.UsuarioCreate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Hash password if provided + passwordHash := req.Password + if passwordHash != "" { + hash, err := h.authService.HashPassword(passwordHash) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al hashear password"}) + return + } + passwordHash = hash + } + + user := &models.Usuario{ + Username: req.Username, + Email: req.Email, + PasswordHash: passwordHash, + Nombre: req.Nombre, + Rol: req.Rol, + } + + // Si no se proporciona email, generar uno automáticamente basado en el username + if user.Email == "" { + user.Email = req.Username + "@econ.local" + } + + if user.Rol == "" { + user.Rol = "estudiante" + } + + err := h.userRepo.Create(c.Request.Context(), user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al crear usuario: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, user) +} + +// GetUser godoc +// @Summary Obtener usuario +// @Description Obtiene un usuario por ID (solo admin) +// @Tags admin +// @Produce json +// @Security BearerAuth +// @Param id path string true "ID del usuario" +// @Success 200 {object} models.Usuario +// @Router /api/admin/usuarios/{id} [get] +func (h *UsersHandler) GetUser(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"}) + return + } + + user, err := h.userRepo.GetByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"}) + return + } + + c.JSON(http.StatusOK, user) +} + +// UpdateUser godoc +// @Summary Actualizar usuario +// @Description Actualiza un usuario (solo admin) +// @Tags admin +// @Accept json +// @Produce json +// @Param id path string true "ID del usuario" +// @Param usuario body models.UsuarioUpdate true "Datos a actualizar" +// @Security BearerAuth +// @Success 200 {object} models.Usuario +// @Router /api/admin/usuarios/{id} [put] +func (h *UsersHandler) UpdateUser(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"}) + return + } + + var req models.UsuarioUpdate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userRepo.GetByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"}) + return + } + + if req.Email != "" { + user.Email = req.Email + } + if req.Nombre != "" { + user.Nombre = req.Nombre + } + if req.Activo != nil { + user.Activo = *req.Activo + } + + err = h.userRepo.Update(c.Request.Context(), user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al actualizar usuario"}) + return + } + + c.JSON(http.StatusOK, user) +} + +// DeleteUser godoc +// @Summary Eliminar usuario +// @Description Desactiva un usuario (solo admin) +// @Tags admin +// @Produce json +// @Security BearerAuth +// @Param id path string true "ID del usuario" +// @Success 200 {object} map[string]string +// @Router /api/admin/usuarios/{id} [delete] +func (h *UsersHandler) DeleteUser(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"}) + return + } + + err = h.userRepo.Delete(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al eliminar usuario"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Usuario desactivado exitosamente"}) +} + +// GetUserProgreso godoc +// @Summary Ver progreso de usuario +// @Description Obtiene el progreso de un usuario (solo admin) +// @Tags admin +// @Produce json +// @Security BearerAuth +// @Param id path string true "ID del usuario" +// @Success 200 {array} models.Progreso +// @Router /api/admin/usuarios/{id}/progreso [get] +func (h *UsersHandler) GetUserProgreso(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"}) + return + } + + progresos, err := h.progresoRepo.GetByUsuarioID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"}) + return + } + + if progresos == nil { + progresos = []models.Progreso{} + } + + c.JSON(http.StatusOK, progresos) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..c29438a --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/ren/econ/backend/internal/services" +) + +func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header requerido"}) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Formato de authorization header inválido"}) + c.Abort() + return + } + + claims, err := authService.ValidateToken(parts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("user_email", claims.Email) + c.Set("user_rol", claims.Rol) + c.Next() + } +} + +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + rol, exists := c.Get("user_rol") + if !exists || rol != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Acceso denegado - se requiere rol admin"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/models/contenido.go b/backend/internal/models/contenido.go new file mode 100644 index 0000000..bb79da8 --- /dev/null +++ b/backend/internal/models/contenido.go @@ -0,0 +1,23 @@ +package models + +type Modulo struct { + Numero int `json:"numero"` + Titulo string `json:"titulo"` + Descripcion string `json:"descripcion"` + Ejercicios []Ejercicio `json:"ejercicios"` +} + +type Ejercicio struct { + ID int `json:"id"` + Numero int `json:"numero"` + Titulo string `json:"titulo"` + Tipo string `json:"tipo"` + Contenido map[string]interface{} `json:"contenido"` + Orden int `json:"orden"` +} + +type ModuloResumen struct { + Numero int `json:"numero"` + Titulo string `json:"titulo"` + Descripcion string `json:"descripcion"` +} diff --git a/backend/internal/models/progreso.go b/backend/internal/models/progreso.go new file mode 100644 index 0000000..29004c5 --- /dev/null +++ b/backend/internal/models/progreso.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Progreso struct { + ID uuid.UUID `json:"id"` + UsuarioID uuid.UUID `json:"usuario_id"` + ModuloNumero int `json:"modulo_numero"` + EjercicioID int `json:"ejercicio_id"` + Completado bool `json:"completado"` + Puntuacion int `json:"puntuacion"` + Intentos int `json:"intentos"` + UltimaVez time.Time `json:"ultima_vez"` + RespuestaJSON string `json:"respuesta_json,omitempty"` +} + +type ProgresoUpdate struct { + Completado bool `json:"completado"` + Puntuacion int `json:"puntuacion"` + RespuestaJSON string `json:"respuesta_json,omitempty"` +} + +type ProgresoResumen struct { + TotalEjercicios int `json:"total_ejercicios"` + EjerciciosCompletados int `json:"ejercicios_completados"` + PromedioPuntuacion int `json:"promedio_puntuacion"` + ModulosCompletados int `json:"modulos_completados"` +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..00cdc8d --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,50 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Usuario struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Nombre string `json:"nombre"` + Rol string `json:"rol"` // admin, estudiante + CreadoEn time.Time `json:"creado_en"` + UltimoLogin *time.Time `json:"ultimo_login"` + Activo bool `json:"activo"` +} + +type UsuarioCreate struct { + Username string `json:"username" binding:"required"` + Email string `json:"email"` + Password string `json:"password"` + Nombre string `json:"nombre" binding:"required"` + Rol string `json:"rol"` +} + +type UsuarioUpdate struct { + Email string `json:"email"` + Nombre string `json:"nombre"` + Activo *bool `json:"activo"` +} + +type LoginRequest struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password" binding:"required"` +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` // seconds + User *Usuario `json:"user"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} diff --git a/backend/internal/repository/contenido.go b/backend/internal/repository/contenido.go new file mode 100644 index 0000000..53350d7 --- /dev/null +++ b/backend/internal/repository/contenido.go @@ -0,0 +1,89 @@ +package repository + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/ren/econ/backend/internal/models" +) + +type ContenidoRepository struct { + db *pgxpool.Pool +} + +func NewContenidoRepository(db *pgxpool.Pool) *ContenidoRepository { + return &ContenidoRepository{db: db} +} + +func (r *ContenidoRepository) GetModulos(ctx context.Context) ([]models.ModuloResumen, error) { + query := ` + SELECT DISTINCT modulo_numero, titulo, contenido::text + FROM ejercicios + ORDER BY modulo_numero + ` + rows, err := r.db.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var modulos []models.ModuloResumen + for rows.Next() { + var m models.ModuloResumen + var contenidoJSON string + err := rows.Scan(&m.Numero, &m.Titulo, &contenidoJSON) + if err != nil { + return nil, err + } + // Extraer descripción del contenido JSON + var contenido map[string]interface{} + json.Unmarshal([]byte(contenidoJSON), &contenido) + if desc, ok := contenido["descripcion"].(string); ok { + m.Descripcion = desc + } else { + m.Descripcion = "" + } + modulos = append(modulos, m) + } + return modulos, nil +} + +func (r *ContenidoRepository) GetModulo(ctx context.Context, numero int) (*models.Modulo, error) { + query := ` + SELECT id, titulo, tipo, contenido, orden + FROM ejercicios WHERE modulo_numero = $1 + ORDER BY orden + ` + rows, err := r.db.Query(ctx, query, numero) + if err != nil { + return nil, err + } + defer rows.Close() + + modulo := &models.Modulo{Numero: numero} + var ejercicios []models.Ejercicio + + for rows.Next() { + var e models.Ejercicio + var contenidoJSON string + err := rows.Scan(&e.ID, &e.Titulo, &e.Tipo, &contenidoJSON, &e.Orden) + if err != nil { + return nil, err + } + json.Unmarshal([]byte(contenidoJSON), &e.Contenido) + e.Numero = e.ID + ejercicios = append(ejercicios, e) + } + + if len(ejercicios) > 0 { + modulo.Titulo = ejercicios[0].Titulo + modulo.Ejercicios = ejercicios + // Extraer descripción del primer ejercicio + if desc, ok := ejercicios[0].Contenido["descripcion"].(string); ok { + modulo.Descripcion = desc + } + } + + return modulo, nil +} diff --git a/backend/internal/repository/progreso.go b/backend/internal/repository/progreso.go new file mode 100644 index 0000000..7459bad --- /dev/null +++ b/backend/internal/repository/progreso.go @@ -0,0 +1,141 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/ren/econ/backend/internal/models" +) + +type ProgresoRepository struct { + db *pgxpool.Pool +} + +func NewProgresoRepository(db *pgxpool.Pool) *ProgresoRepository { + return &ProgresoRepository{db: db} +} + +func (r *ProgresoRepository) GetByUsuario(ctx context.Context, usuarioID uuid.UUID) ([]models.Progreso, error) { + query := ` + SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json + FROM progreso_usuario WHERE usuario_id = $1 + ORDER BY ultima_vez DESC + ` + rows, err := r.db.Query(ctx, query, usuarioID) + if err != nil { + return nil, err + } + defer rows.Close() + + var progresos []models.Progreso + for rows.Next() { + var p models.Progreso + err := rows.Scan( + &p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID, + &p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez, &p.RespuestaJSON) + if err != nil { + return nil, err + } + progresos = append(progresos, p) + } + return progresos, nil +} + +func (r *ProgresoRepository) GetByModulo(ctx context.Context, usuarioID uuid.UUID, moduloNumero int) ([]models.Progreso, error) { + query := ` + SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json + FROM progreso_usuario WHERE usuario_id = $1 AND modulo_numero = $2 + ORDER BY ejercicio_id + ` + rows, err := r.db.Query(ctx, query, usuarioID, moduloNumero) + if err != nil { + return nil, err + } + defer rows.Close() + + var progresos []models.Progreso + for rows.Next() { + var p models.Progreso + err := rows.Scan( + &p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID, + &p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez, &p.RespuestaJSON) + if err != nil { + return nil, err + } + progresos = append(progresos, p) + } + return progresos, nil +} + +func (r *ProgresoRepository) GetByEjercicio(ctx context.Context, usuarioID uuid.UUID, ejercicioID int) (*models.Progreso, error) { + query := ` + SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json + FROM progreso_usuario WHERE usuario_id = $1 AND ejercicio_id = $2 + ` + var p models.Progreso + err := r.db.QueryRow(ctx, query, usuarioID, ejercicioID).Scan( + &p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID, + &p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez, &p.RespuestaJSON) + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *ProgresoRepository) Upsert(ctx context.Context, usuarioID uuid.UUID, ejercicioID int, update *models.ProgresoUpdate) error { + query := ` + INSERT INTO progreso_usuario (id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez, respuesta_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (usuario_id, modulo_numero, ejercicio_id) + DO UPDATE SET completado = $5, puntuacion = $6, intentos = $7, ultima_vez = $8, respuesta_json = $9 + ` + + moduloNumero, err := r.getModuloByEjercicio(ctx, ejercicioID) + if err != nil { + return err + } + + existing, _ := r.GetByEjercicio(ctx, usuarioID, ejercicioID) + var intentos int + if existing != nil { + intentos = existing.Intentos + 1 + } else { + intentos = 1 + } + + _, err = r.db.Exec(ctx, query, + uuid.New(), usuarioID, moduloNumero, ejercicioID, + update.Completado, update.Puntuacion, intentos, time.Now(), update.RespuestaJSON) + return err +} + +func (r *ProgresoRepository) getModuloByEjercicio(ctx context.Context, ejercicioID int) (int, error) { + var moduloNumero int + err := r.db.QueryRow(ctx, "SELECT modulo_numero FROM ejercicios WHERE id = $1", ejercicioID).Scan(&moduloNumero) + return moduloNumero, err +} + +func (r *ProgresoRepository) GetResumen(ctx context.Context, usuarioID uuid.UUID) (*models.ProgresoResumen, error) { + query := ` + SELECT + COUNT(DISTINCT ejercicio_id) as total, + COUNT(CASE WHEN completado THEN 1 END) as completados, + COALESCE(AVG(CASE WHEN completado THEN puntuacion END), 0)::int as promedio, + COUNT(DISTINCT CASE WHEN completado THEN modulo_numero END) as modulos + FROM progreso_usuario WHERE usuario_id = $1 + ` + var resumen models.ProgresoResumen + err := r.db.QueryRow(ctx, query, usuarioID).Scan( + &resumen.TotalEjercicios, &resumen.EjerciciosCompletados, + &resumen.PromedioPuntuacion, &resumen.ModulosCompletados) + if err != nil { + return nil, err + } + return &resumen, nil +} + +func (r *ProgresoRepository) GetByUsuarioID(ctx context.Context, usuarioID uuid.UUID) ([]models.Progreso, error) { + return r.GetByUsuario(ctx, usuarioID) +} diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go new file mode 100644 index 0000000..8d4686f --- /dev/null +++ b/backend/internal/repository/user.go @@ -0,0 +1,128 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/ren/econ/backend/internal/models" +) + +type UserRepository struct { + db *pgxpool.Pool +} + +func NewUserRepository(db *pgxpool.Pool) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(ctx context.Context, user *models.Usuario) error { + user.ID = uuid.New() + user.CreadoEn = time.Now() + user.Activo = true + if user.Rol == "" { + user.Rol = "estudiante" + } + + query := ` + INSERT INTO usuarios (id, email, username, password_hash, nombre, rol, creado_en, activo) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ` + _, err := r.db.Exec(ctx, query, + user.ID, user.Email, user.Username, user.PasswordHash, user.Nombre, user.Rol, user.CreadoEn, user.Activo) + return err +} + +func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Usuario, error) { + query := ` + SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo + FROM usuarios WHERE id = $1 + ` + var user models.Usuario + err := r.db.QueryRow(ctx, query, id).Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre, + &user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo) + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.Usuario, error) { + query := ` + SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo + FROM usuarios WHERE email = $1 + ` + var user models.Usuario + err := r.db.QueryRow(ctx, query, email).Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre, + &user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo) + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.Usuario, error) { + query := ` + SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo + FROM usuarios WHERE username = $1 + ` + var user models.Usuario + err := r.db.QueryRow(ctx, query, username).Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre, + &user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo) + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) List(ctx context.Context) ([]models.Usuario, error) { + query := ` + SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo + FROM usuarios ORDER BY creado_en DESC + ` + rows, err := r.db.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []models.Usuario + for rows.Next() { + var user models.Usuario + err := rows.Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre, + &user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +func (r *UserRepository) Update(ctx context.Context, user *models.Usuario) error { + query := ` + UPDATE usuarios SET email = $2, nombre = $3, rol = $4, activo = $5 + WHERE id = $1 + ` + _, err := r.db.Exec(ctx, query, + user.ID, user.Email, user.Nombre, user.Rol, user.Activo) + return err +} + +func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error { + // Soft delete - set activo to false + query := `UPDATE usuarios SET activo = false WHERE id = $1` + _, err := r.db.Exec(ctx, query, id) + return err +} + +func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error { + query := `UPDATE usuarios SET ultimo_login = $2 WHERE id = $1` + _, err := r.db.Exec(ctx, query, id, time.Now()) + return err +} diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go new file mode 100644 index 0000000..b5ac468 --- /dev/null +++ b/backend/internal/services/auth.go @@ -0,0 +1,195 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/ren/econ/backend/internal/config" + "github.com/ren/econ/backend/internal/models" + "github.com/ren/econ/backend/internal/repository" +) + +var ( + ErrInvalidCredentials = errors.New("credenciales inválidas") + ErrUserNotFound = errors.New("usuario no encontrado") + ErrUserInactive = errors.New("usuario inactivo") + ErrInvalidToken = errors.New("token inválido") + ErrTokenExpired = errors.New("token expirado") +) + +type AuthService struct { + config *config.Config + userRepo *repository.UserRepository +} + +func NewAuthService(cfg *config.Config, userRepo *repository.UserRepository) *AuthService { + return &AuthService{ + config: cfg, + userRepo: userRepo, + } +} + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + Rol string `json:"rol"` + jwt.RegisteredClaims +} + +func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error) { + var user *models.Usuario + var err error + + // Buscar por email o username + if req.Username != "" { + user, err = s.userRepo.GetByUsername(ctx, req.Username) + } else if req.Email != "" { + user, err = s.userRepo.GetByEmail(ctx, req.Email) + } else { + return nil, ErrInvalidCredentials + } + + if err != nil { + return nil, ErrInvalidCredentials + } + + if !user.Activo { + return nil, ErrUserInactive + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return nil, ErrInvalidCredentials + } + + // Update last login + s.userRepo.UpdateLastLogin(ctx, user.ID) + + // Generate tokens + accessToken, err := s.generateAccessToken(user) + if err != nil { + return nil, fmt.Errorf("error generando access token: %w", err) + } + + refreshToken, err := s.generateRefreshToken(user) + if err != nil { + return nil, fmt.Errorf("error generando refresh token: %w", err) + } + + return &models.LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: s.config.JWTExpirationHours * 60, + User: user, + }, nil +} + +func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*models.LoginResponse, error) { + claims, err := s.validateToken(refreshToken) + if err != nil { + return nil, ErrInvalidToken + } + + user, err := s.userRepo.GetByID(ctx, claims.UserID) + if err != nil { + return nil, ErrUserNotFound + } + + if !user.Activo { + return nil, ErrUserInactive + } + + accessToken, err := s.generateAccessToken(user) + if err != nil { + return nil, fmt.Errorf("error generando access token: %w", err) + } + + newRefreshToken, err := s.generateRefreshToken(user) + if err != nil { + return nil, fmt.Errorf("error generando refresh token: %w", err) + } + + return &models.LoginResponse{ + AccessToken: accessToken, + RefreshToken: newRefreshToken, + ExpiresIn: s.config.JWTExpirationHours * 60, + User: user, + }, nil +} + +func (s *AuthService) Logout(ctx context.Context, userID uuid.UUID) error { + // In a more complete implementation, we would blacklist the tokens + // For now, just return success + return nil +} + +func (s *AuthService) HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func (s *AuthService) generateAccessToken(user *models.Usuario) (string, error) { + claims := Claims{ + UserID: user.ID, + Email: user.Email, + Rol: user.Rol, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpirationHours) * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Subject: user.ID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.config.JWTSecret)) +} + +func (s *AuthService) generateRefreshToken(user *models.Usuario) (string, error) { + claims := Claims{ + UserID: user.ID, + Email: user.Email, + Rol: user.Rol, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.RefreshExpDays) * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Subject: user.ID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.config.JWTSecret)) +} + +func (s *AuthService) validateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.config.JWTSecret), nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrTokenExpired + } + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + + return claims, nil +} + +func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { + return s.validateToken(tokenString) +} diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..fee4025 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,44 @@ +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create usuarios table +CREATE TABLE IF NOT EXISTS usuarios ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + nombre VARCHAR(255) NOT NULL, + rol VARCHAR(50) DEFAULT 'estudiante', + creado_en TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + ultimo_login TIMESTAMP WITH TIME ZONE, + activo BOOLEAN DEFAULT true +); + +-- Create ejercicios table +CREATE TABLE IF NOT EXISTS ejercicios ( + id SERIAL PRIMARY KEY, + modulo_numero INTEGER NOT NULL, + titulo VARCHAR(255) NOT NULL, + tipo VARCHAR(50) NOT NULL, + contenido JSONB NOT NULL, + orden INTEGER DEFAULT 0 +); + +-- Create progreso_usuario table +CREATE TABLE IF NOT EXISTS progreso_usuario ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, + modulo_numero INTEGER NOT NULL, + ejercicio_id INTEGER REFERENCES ejercicios(id) ON DELETE SET NULL, + completado BOOLEAN DEFAULT false, + puntuacion INTEGER DEFAULT 0, + intentos INTEGER DEFAULT 0, + ultima_vez TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + respuesta_json JSONB, + UNIQUE(usuario_id, modulo_numero, ejercicio_id) +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email); +CREATE INDEX IF NOT EXISTS idx_progreso_usuario_usuario ON progreso_usuario(usuario_id); +CREATE INDEX IF NOT EXISTS idx_ejercicios_modulo ON ejercicios(modulo_numero, orden); +CREATE INDEX IF NOT EXISTS idx_progreso_usuario_ejercicio ON progreso_usuario(usuario_id, ejercicio_id); diff --git a/contexto.md b/contexto.md new file mode 100644 index 0000000..2fa786b --- /dev/null +++ b/contexto.md @@ -0,0 +1,40 @@ +# Contexto del Proyecto - Plataforma de Economía + +## Objetivo +Plataforma educativa para aprender economía mediante 4 módulos interactivos con ejercicios. + +## URLs +- **Producción**: https://eco.cbcren.online (actualmente no funciona por problema de Caddy) +- **Temporal**: http://194.163.191.200:3002 + +## Tech Stack +- **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS +- **Backend**: Go + Gin +- **Base de datos**: PostgreSQL +- **Auth**: JWT +- **Docker**: Docker Compose + +## Estructura +``` +/home/ren/econ/ +├── frontend/ # React app +├── backend/ # Go API +├── docker-compose.yml +├── .env +└── tofix.md # Problema a resolver +``` + +## Estado +- Frontend: ✅ Compila y funciona en puerto 3002 +- Backend: ✅ Compila y funciona en puerto 8080 +- PostgreSQL: ✅ Corriendo en puerto 5433 +- Caddy proxy: ❌ No puede acceder a los contenedores + +## Credenciales +- Admin por defecto: `admin@econ.local` / `admin123` (se crea automáticamente) + +## Archivos importantes +- `/home/ren/econ/README.md` - Documentación general +- `/home/ren/econ/TECH_SPECS.md` - Especificaciones técnicas +- `/home/ren/econ/TODO.md` - Tareas pendientes +- `/home/ren/econ/tofix.md` - Problema actual diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..36fa12c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + postgres: + image: postgres:16-alpine + container_name: econ-postgres + environment: + POSTGRES_USER: ${DB_USER:-econ_user} + POSTGRES_PASSWORD: ${DB_PASSWORD:-econ_pass} + POSTGRES_DB: ${DB_NAME:-econ_db} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-econ_user} -d ${DB_NAME:-econ_db}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: econ-backend + environment: + DB_HOST: econ-postgres + DB_PORT: 5432 + DB_USER: ${DB_USER:-econ_user} + DB_PASSWORD: ${DB_PASSWORD:-econ_pass} + DB_NAME: ${DB_NAME:-econ_db} + JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production} + SERVER_PORT: 8080 + ports: + - "8080:8080" + networks: + - caddy + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: econ-frontend + ports: + - "3002:80" + networks: + - caddy + depends_on: + - backend + +volumes: + postgres_data: + +networks: + caddy: + external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b9ec400 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the app +RUN npm run build + +# Final stage - nginx +FROM nginx:alpine + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9cd3d67 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + Plataforma de Economía + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d5dfa96 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Handle SPA routing - try files first, then fall back to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "ok"; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://econ-backend:8080/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a3a2d8e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3010 @@ +{ + "name": "econ-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "econ-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..43e6e7c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "econ-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.2", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9f1d0f9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,70 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from './stores/authStore'; +import { Login } from './pages/Login'; +import { Dashboard } from './pages/Dashboard'; +import { Modulos } from './pages/Modulos'; +import { Modulo } from './pages/Modulo'; +import { AdminPanel } from './pages/admin/AdminPanel'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuthStore(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +function App() { + return ( + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + ); +} + +export default App; diff --git a/frontend/src/components/admin/UserForm.tsx b/frontend/src/components/admin/UserForm.tsx new file mode 100644 index 0000000..f9f8cc0 --- /dev/null +++ b/frontend/src/components/admin/UserForm.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { usuarioService } from '../../services/api'; +import type { Usuario } from '../../types'; +import { Button } from '../ui/Button'; +import { Input } from '../ui/Input'; +import { X } from 'lucide-react'; + +interface UserFormProps { + usuario: Usuario | null; + onClose: () => void; +} + +export function UserForm({ usuario, onClose }: UserFormProps) { + const [formData, setFormData] = useState({ + nombre: usuario?.nombre || '', + username: usuario?.username || '', + email: usuario?.email || '', + password: '', + rol: usuario?.rol || 'estudiante' as 'admin' | 'estudiante', + activo: usuario?.activo ?? true, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + // Validaciones + if (!formData.username.trim()) { + setError('El nombre de usuario es requerido'); + setLoading(false); + return; + } + + if (!formData.nombre.trim()) { + setError('El nombre completo es requerido'); + setLoading(false); + return; + } + + try { + if (usuario) { + await usuarioService.updateUsuario(usuario.id, formData); + } else { + if (!formData.password) { + setError('La contraseña es requerida para nuevos usuarios'); + setLoading(false); + return; + } + await usuarioService.createUsuario({ + ...formData, + password: formData.password, + } as Omit & { password: string }); + } + onClose(); + } catch (err) { + setError('Error al guardar el usuario'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ {usuario ? 'Editar usuario' : 'Nuevo usuario'} +

+ +
+ + {error && ( +
+ {error} +
+ )} + + setFormData({ ...formData, username: e.target.value })} + placeholder="usuario123" + required + /> + + setFormData({ ...formData, nombre: e.target.value })} + placeholder="Nombre completo" + required + /> + + setFormData({ ...formData, email: e.target.value })} + placeholder="email@ejemplo.com" + /> + + {!usuario && ( + setFormData({ ...formData, password: e.target.value })} + placeholder="••••••••" + required + /> + )} + +
+ + +
+ +
+ setFormData({ ...formData, activo: e.target.checked })} + className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary" + /> + +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/admin/UserList.tsx b/frontend/src/components/admin/UserList.tsx new file mode 100644 index 0000000..52eeb74 --- /dev/null +++ b/frontend/src/components/admin/UserList.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect } from 'react'; +import { usuarioService } from '../../services/api'; +import type { Usuario } from '../../types'; +import { Card, CardHeader } from '../ui/Card'; +import { Button } from '../ui/Button'; +import { Users, UserPlus, Edit, Trash2, Search } from 'lucide-react'; +import { UserForm } from './UserForm'; + +export function UserList() { + const [usuarios, setUsuarios] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [showForm, setShowForm] = useState(false); + const [editingUser, setEditingUser] = useState(null); + + useEffect(() => { + loadUsuarios(); + }, []); + + const loadUsuarios = async () => { + try { + const data = await usuarioService.getUsuarios(); + setUsuarios(data); + } catch (error) { + console.error('Error loading usuarios:', error); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('¿Estás seguro de que deseas eliminar este usuario?')) return; + + try { + await usuarioService.deleteUsuario(id); + setUsuarios(usuarios.filter((u) => u.id !== id)); + } catch (error) { + console.error('Error deleting usuario:', error); + } + }; + + const handleEdit = (usuario: Usuario) => { + setEditingUser(usuario); + setShowForm(true); + }; + + const handleFormClose = () => { + setShowForm(false); + setEditingUser(null); + loadUsuarios(); + }; + + const filteredUsuarios = usuarios.filter( + (u) => + u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (loading) { + return ( + +
+
+
+
+ ); + } + + return ( + + setShowForm(true)}> + + Nuevo usuario + + } + /> + +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {showForm && ( +
+ +
+ )} + +
+ + + + + + + + + + + + {filteredUsuarios.map((usuario) => ( + + + + + + + + ))} + +
NombreEmailRolEstadoAcciones
+
+
+ {usuario.nombre.charAt(0).toUpperCase()} +
+ {usuario.nombre} +
+
{usuario.email} + + {usuario.rol === 'admin' ? 'Admin' : 'Estudiante'} + + + + {usuario.activo ? 'Activo' : 'Inactivo'} + + + + +
+
+ + {filteredUsuarios.length === 0 && ( +
+ +

No se encontraron usuarios

+
+ )} +
+ ); +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..3e03dd2 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +import { Button } from '../ui/Button'; +import { Input } from '../ui/Input'; +import { Mail, Lock, LogIn } from 'lucide-react'; + +export function LoginForm() { + const navigate = useNavigate(); + const { login, isLoading, error, clearError } = useAuthStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [validationError, setValidationError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearError(); + setValidationError(''); + + if (!email || !password) { + setValidationError('Por favor, completa todos los campos'); + return; + } + + try { + // Determinar si es email o username + const isEmail = email.includes('@'); + if (isEmail) { + await login({ email, password }); + } else { + await login({ username: email, password }); + } + navigate('/'); + } catch { + // Error handled by store + } + }; + + return ( +
+ {(error || validationError) && ( +
+ {error || validationError} +
+ )} + + setEmail(e.target.value)} + icon={} + autoComplete="username" + /> + + setPassword(e.target.value)} + icon={} + autoComplete="current-password" + /> + + + + ); +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..d6af066 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,45 @@ +import { ButtonHTMLAttributes, forwardRef } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; +} + +export const Button = forwardRef( + ({ className = '', variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => { + const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; + + const variants = { + primary: 'bg-primary text-white hover:bg-blue-700 focus:ring-blue-500', + secondary: 'bg-secondary text-white hover:bg-violet-700 focus:ring-violet-500', + outline: 'border-2 border-primary text-primary hover:bg-primary hover:text-white focus:ring-primary', + ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-500', + }; + + const sizes = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + return ( + + ); + } +); + +Button.displayName = 'Button'; diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..26d9546 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,32 @@ +import { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; +} + +export function Card({ children, className = '' }: CardProps) { + return ( +
+ {children} +
+ ); +} + +interface CardHeaderProps { + title: string; + subtitle?: string; + action?: ReactNode; +} + +export function CardHeader({ title, subtitle, action }: CardHeaderProps) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action &&
{action}
} +
+ ); +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..9b8058a --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,48 @@ +import { InputHTMLAttributes, forwardRef, ReactNode } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + icon?: ReactNode; +} + +export const Input = forwardRef( + ({ className = '', label, error, icon, id, ...props }, ref) => { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-'); + + return ( +
+ {label && ( + + )} +
+ {icon && ( +
+ {icon} +
+ )} + +
+ {error && ( +

{error}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a69a6c6 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,54 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@layer utilities { + .text-primary { + @apply text-blue-600; + } + .bg-primary { + @apply bg-blue-600; + } + .hover\:bg-primary:hover { + @apply bg-blue-700; + } + .focus\:ring-primary:focus { + --tw-ring-color: rgb(37 99 235); + } + .bg-success { + @apply bg-emerald-500; + } + .text-success { + @apply text-emerald-500; + } + .bg-secondary { + @apply bg-violet-600; + } + .text-secondary { + @apply text-violet-600; + } + .bg-warning { + @apply bg-amber-500; + } + .text-warning { + @apply text-amber-500; + } + .bg-error { + @apply bg-red-500; + } + .text-error { + @apply text-red-500; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..9aa52ff --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..53777a1 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; +import { Card } from '../components/ui/Card'; +import { Button } from '../components/ui/Button'; +import { progresoService } from '../services/api'; +import type { ModuloProgreso } from '../types'; +import { BookOpen, TrendingUp, User, LogOut, LayoutGrid } from 'lucide-react'; + +const MODULOS_DEFAULT = [ + { numero: 1, titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos' }, + { numero: 2, titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de mercado' }, + { numero: 3, titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor' }, + { numero: 4, titulo: 'Teoría del Productor', descripcion: 'Costos y producción' }, +]; + +export function Dashboard() { + const { usuario, logout } = useAuthStore(); + const [modulosProgreso, setModulosProgreso] = useState([]); + + useEffect(() => { + loadProgreso(); + }, []); + + const loadProgreso = async () => { + try { + const progresos = await progresoService.getProgreso(); + const modulos = MODULOS_DEFAULT.map((mod) => { + const modProgresos = progresos.filter((p) => p.modulo_numero === mod.numero); + const completados = modProgresos.filter((p) => p.completado).length; + const total = 5; // Asumiendo 5 ejercicios por módulo + return { + numero: mod.numero, + titulo: mod.titulo, + porcentaje: Math.round((completados / total) * 100), + ejerciciosCompletados: completados, + totalEjercicios: total, + }; + }); + setModulosProgreso(modulos); + } catch { + // Si hay error, mostrar progreso vacío + setModulosProgreso( + MODULOS_DEFAULT.map((mod) => ({ + numero: mod.numero, + titulo: mod.titulo, + porcentaje: 0, + ejerciciosCompletados: 0, + totalEjercicios: 5, + })) + ); + } + }; + + const handleLogout = async () => { + await logout(); + }; + + const totalProgreso = Math.round( + modulosProgreso.reduce((acc, mod) => acc + mod.porcentaje, 0) / modulosProgreso.length + ); + + return ( +
+
+
+
+
+ +
+

Economía

+
+ +
+
+ + {usuario?.nombre} + {usuario?.rol === 'admin' && ( + + Admin + + )} +
+ +
+
+
+ +
+
+

Tu progreso

+

Continúa donde lo dejaste

+
+ + +
+
+

Progreso total

+

{totalProgreso}% completado

+
+
{totalProgreso}%
+
+
+
+
+ + +
+

Módulos

+ {usuario?.rol === 'admin' && ( + + + + )} +
+ +
+ {modulosProgreso.map((modulo) => ( + + +
+
+
+ {modulo.numero} +
+
+

{modulo.titulo}

+

+ {modulo.ejerciciosCompletados}/{modulo.totalEjercicios} ejercicios +

+
+
+
+ +
+
+
+ +
+ {modulo.porcentaje}% completado + {modulo.porcentaje === 100 && ( + + + Completado + + )} +
+ + + ))} +
+ +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..4f08a58 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,34 @@ +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; +import { LoginForm } from '../components/auth/LoginForm'; +import { BookOpen } from 'lucide-react'; + +export function Login() { + const { isAuthenticated } = useAuthStore(); + + if (isAuthenticated) { + return ; + } + + return ( +
+
+
+
+ +
+

Plataforma de Economía

+

Inicia sesión para continuar

+
+ +
+ +
+ +

+ Sistema de aprendizaje interactivo +

+
+
+ ); +} diff --git a/frontend/src/pages/Modulo.tsx b/frontend/src/pages/Modulo.tsx new file mode 100644 index 0000000..0e43e98 --- /dev/null +++ b/frontend/src/pages/Modulo.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Card } from '../components/ui/Card'; +import { Button } from '../components/ui/Button'; +import { progresoService } from '../services/api'; +import type { Progreso } from '../types'; +import { ArrowLeft, CheckCircle, Play } from 'lucide-react'; + +const MODULOS_INFO: Record = { + 1: { titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos de economía' }, + 2: { titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de oferta y demanda en el mercado' }, + 3: { titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor y elasticidades' }, + 4: { titulo: 'Teoría del Productor', descripcion: 'Costos de producción y competencia perfecta' }, +}; + +const EJERCICIOS_MOCK = [ + { id: 'e1', titulo: 'Conceptos básicos', descripcion: 'Repasa los fundamentos de la economía' }, + { id: 'e2', titulo: 'Agentes económicos', descripcion: 'Identifica los diferentes agentes en la economía' }, + { id: 'e3', titulo: 'Factores de producción', descripcion: 'Aprende sobre tierra, trabajo y capital' }, + { id: 'e4', titulo: 'Flujo circular', descripcion: 'Comprende el flujo de bienes y dinero' }, + { id: 'e5', titulo: 'Evaluación final', descripcion: 'Pon a prueba todo lo aprendido' }, +]; + +export function Modulo() { + const { numero } = useParams<{ numero: string }>(); + const num = parseInt(numero || '1', 10); + const [progresos, setProgresos] = useState([]); + + const moduloInfo = MODULOS_INFO[num] || MODULOS_INFO[1]; + const ejercicios = EJERCICIOS_MOCK; + + useEffect(() => { + loadProgreso(); + }, [num]); + + const loadProgreso = async () => { + try { + const data = await progresoService.getProgreso(); + setProgresos(data); + } catch { + // Silencio + } + }; + + const getProgresoForEjercicio = (ejercicioId: string) => { + return progresos.find( + (p) => p.modulo_numero === num && p.ejercicio_id === ejercicioId + ); + }; + + const completados = ejercicios.filter( + (e) => getProgresoForEjercicio(e.id)?.completado + ).length; + const porcentaje = Math.round((completados / ejercicios.length) * 100); + + return ( +
+
+
+ + + Volver al Dashboard + +
+
+ +
+
+
+
+ {num} +
+
+

{moduloInfo.titulo}

+

{moduloInfo.descripcion}

+
+
+ + +
+
+

Tu progreso en este módulo

+

{porcentaje}%

+
+
+

{completados}/{ejercicios.length} ejercicios

+
+
+
+
+
+ +
+ +

Ejercicios

+ +
+ {ejercicios.map((ejercicio, index) => { + const progreso = getProgresoForEjercicio(ejercicio.id); + const completado = progreso?.completado || false; + + return ( + +
+
+ {completado ? ( + + ) : ( + {index + 1} + )} +
+ +
+

{ejercicio.titulo}

+

{ejercicio.descripcion}

+
+ + +
+
+ ); + })} +
+ + {porcentaje === 100 && ( + +
+
+ +
+
+

¡Felicitaciones!

+

+ Has completado todos los ejercicios de este módulo. +

+
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Modulos.tsx b/frontend/src/pages/Modulos.tsx new file mode 100644 index 0000000..338df74 --- /dev/null +++ b/frontend/src/pages/Modulos.tsx @@ -0,0 +1,95 @@ +import { Link } from 'react-router-dom'; +import { Card } from '../components/ui/Card'; +import { Button } from '../components/ui/Button'; +import { ArrowRight, ArrowLeft } from 'lucide-react'; + +const MODULOS = [ + { + numero: 1, + titulo: 'Fundamentos de Economía', + descripcion: 'Aprende los conceptos básicos: definición de economía, agentes económicos, factores de producción y el flujo circular de la economía.', + temas: ['Definición de economía', 'Agentes económicos', 'Factores de producción', 'Flujo circular'], + }, + { + numero: 2, + titulo: 'Oferta, Demanda y Equilibrio', + descripcion: 'Domina las curvas de oferta y demanda, aprende cómo se determinan los precios y entiende los controles de mercado.', + temas: ['Curva de demanda', 'Curva de oferta', 'Equilibrio de mercado', 'Controles de precios'], + }, + { + numero: 3, + titulo: 'Utilidad y Elasticidad', + descripcion: 'Explora la teoría del consumidor, aprende a calcular elasticidades y clasifica diferentes tipos de bienes.', + temas: ['Utilidad marginal', 'Elasticidad precio', 'Elasticidad ingreso', 'Clasificación de bienes'], + }, + { + numero: 4, + titulo: 'Teoría del Productor', + descripcion: 'Comprende los costos de producción, la toma de decisiones del productor y los fundamentos de la competencia perfecta.', + temas: ['Costos de producción', 'Producción y costos', 'Competencia perfecta', 'Maximización de beneficios'], + }, +]; + +export function Modulos() { + return ( +
+
+
+ + + Volver al Dashboard + +
+
+ +
+
+

Módulos Educativos

+

+ Explora los 4 módulos de economía. Cada uno contiene ejercicios interactivos + para fortalecer tu comprensión de los conceptos. +

+
+ +
+ {MODULOS.map((modulo) => ( + +
+
+
+ {modulo.numero} +
+
+ +
+

{modulo.titulo}

+

{modulo.descripcion}

+ +
+ {modulo.temas.map((tema) => ( + + {tema} + + ))} +
+
+ +
+ + + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminPanel.tsx b/frontend/src/pages/admin/AdminPanel.tsx new file mode 100644 index 0000000..8fe0586 --- /dev/null +++ b/frontend/src/pages/admin/AdminPanel.tsx @@ -0,0 +1,39 @@ +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +import { UserList } from '../../components/admin/UserList'; +import { ArrowLeft, Settings } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +export function AdminPanel() { + const { usuario } = useAuthStore(); + + if (!usuario || usuario.rol !== 'admin') { + return ; + } + + return ( +
+
+
+
+ + + Volver + +
+
+
+ +
+

Panel de Administración

+
+
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..0a57f9c --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,143 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import type { LoginRequest, LoginResponse, Usuario, Progreso, Modulo } from '../types'; + +const API_BASE_URL = '/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +let refreshPromise: Promise | null = null; + +const getStoredToken = () => localStorage.getItem('token'); +const getStoredRefreshToken = () => localStorage.getItem('refresh_token'); + +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = getStoredToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = getStoredRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token'); + } + + if (!refreshPromise) { + refreshPromise = refreshAccessToken(refreshToken); + } + + const newToken = await refreshPromise; + refreshPromise = null; + + localStorage.setItem('token', newToken); + originalRequest.headers.Authorization = `Bearer ${newToken}`; + + return api(originalRequest); + } catch (refreshError) { + localStorage.removeItem('token'); + localStorage.removeItem('refresh_token'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); + +async function refreshAccessToken(refreshToken: string): Promise { + const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refresh_token: refreshToken, + }); + return response.data.access_token; +} + +export const authService = { + async login(credentials: LoginRequest): Promise { + const response = await api.post('/auth/login', credentials); + localStorage.setItem('token', response.data.access_token); + localStorage.setItem('refresh_token', response.data.refresh_token); + return response.data; + }, + + async logout(): Promise { + try { + await api.post('/auth/logout'); + } finally { + localStorage.removeItem('token'); + localStorage.removeItem('refresh_token'); + } + }, + + async getCurrentUser(): Promise { + const response = await api.get('/auth/me'); + return response.data; + }, +}; + +export const progresoService = { + async getProgreso(): Promise { + const response = await api.get('/progreso'); + return response.data; + }, + + async saveProgreso(progreso: Progreso): Promise { + const response = await api.post('/progreso', progreso); + return response.data; + }, + + async getProgresoByUser(userId: string): Promise { + const response = await api.get(`/admin/usuarios/${userId}/progreso`); + return response.data; + }, +}; + +export const moduloService = { + async getModulos(): Promise { + const response = await api.get('/modulos'); + return response.data; + }, + + async getModulo(numero: number): Promise { + const response = await api.get(`/modulos/${numero}`); + return response.data; + }, +}; + +export const usuarioService = { + async getUsuarios(): Promise { + const response = await api.get('/admin/usuarios'); + return response.data; + }, + + async createUsuario(usuario: Omit & { password: string }): Promise { + const response = await api.post('/admin/usuarios', usuario); + return response.data; + }, + + async updateUsuario(id: string, usuario: Partial): Promise { + const response = await api.put(`/admin/usuarios/${id}`, usuario); + return response.data; + }, + + async deleteUsuario(id: string): Promise { + await api.delete(`/admin/usuarios/${id}`); + }, +}; + +export default api; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..b771158 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,72 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { Usuario, LoginRequest, LoginResponse } from '../types'; +import { authService } from '../services/api'; + +interface AuthState { + usuario: Usuario | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + login: (credentials: LoginRequest) => Promise; + logout: () => Promise; + checkAuth: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + usuario: null, + isAuthenticated: false, + isLoading: false, + error: null, + + login: async (credentials: LoginRequest) => { + set({ isLoading: true, error: null }); + try { + const response: LoginResponse = await authService.login(credentials); + set({ + usuario: response.user, + isAuthenticated: true, + isLoading: false, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al iniciar sesión'; + set({ error: message, isLoading: false }); + throw error; + } + }, + + logout: async () => { + try { + await authService.logout(); + } finally { + set({ usuario: null, isAuthenticated: false }); + } + }, + + checkAuth: async () => { + const token = localStorage.getItem('token'); + if (!token) { + set({ isAuthenticated: false, usuario: null }); + return; + } + + set({ isLoading: true }); + try { + const usuario = await authService.getCurrentUser(); + set({ usuario, isAuthenticated: true, isLoading: false }); + } catch { + set({ usuario: null, isAuthenticated: false, isLoading: false }); + } + }, + + clearError: () => set({ error: null }), + }), + { + name: 'auth-storage', + partialize: (state) => ({ isAuthenticated: state.isAuthenticated }), + } + ) +); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..4d92cb0 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,54 @@ +export interface Usuario { + id: string; + username: string; + email: string; + nombre: string; + rol: 'admin' | 'estudiante'; + activo: boolean; +} + +export interface Progreso { + modulo_numero: number; + ejercicio_id: string; + completado: boolean; + puntuacion: number; +} + +export interface Ejercicio { + id: string; + titulo: string; + descripcion: string; + tipo: 'quiz' | 'simulador' | 'ejercicio'; +} + +export interface Modulo { + numero: number; + titulo: string; + descripcion: string; + ejercicios: Ejercicio[]; +} + +export interface ModuloProgreso { + numero: number; + titulo: string; + porcentaje: number; + ejerciciosCompletados: number; + totalEjercicios: number; +} + +export interface LoginRequest { + email?: string; + username?: string; + password: string; +} + +export interface LoginResponse { + access_token: string; + refresh_token: string; + user: Usuario; +} + +export interface ApiError { + message: string; + code?: string; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..44d68f8 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,23 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: '#2563eb', + secondary: '#7c3aed', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + mono: ['JetBrains Mono', 'monospace'], + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..28e3e8d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +}) diff --git a/resumen_clase_1.pdf b/resumen_clase_1.pdf new file mode 100644 index 0000000..f68a3dc Binary files /dev/null and b/resumen_clase_1.pdf differ diff --git a/resumen_clase_2.pdf b/resumen_clase_2.pdf new file mode 100644 index 0000000..caf4530 Binary files /dev/null and b/resumen_clase_2.pdf differ diff --git a/resumen_clase_3.pdf b/resumen_clase_3.pdf new file mode 100644 index 0000000..326b3af Binary files /dev/null and b/resumen_clase_3.pdf differ diff --git a/resumen_clase_4.pdf b/resumen_clase_4.pdf new file mode 100644 index 0000000..b13c804 Binary files /dev/null and b/resumen_clase_4.pdf differ diff --git a/tofix.md b/tofix.md new file mode 100644 index 0000000..e37d467 --- /dev/null +++ b/tofix.md @@ -0,0 +1,47 @@ +# Problema: Caddy no puede acceder a los contenedores de econ + +## Estado actual + +Los contenedores están corriendo: +- `econ-postgres` (PostgreSQL) +- `econ-backend` (Go API, puerto 8080) +- `econ-frontend` (React/Nginx, puerto 80) + +Todos están conectados a la red `caddy` pero Caddy no puede resolver sus nombres. + +## Síntoma + +``` +dial tcp: lookup econ-frontend on 127.0.0.11:53: no such host +``` + +## Qué se intentó + +1. Usar nombres de contenedor en Caddy (econ-frontend:80) - NO funciona +2. Usar IP del host (194.163.191.200:3002) - NO funciona (timeout) +3. Usar IP del contenedor en Caddy (172.20.0.x) - NO funciona +4. network_mode: host - Rompió nginx por depender de "backend" + +## Cómo funciona Gitea (y funciona) + +- Gitea está en red `gitea_gitea-network` +- Caddy tiene: `reverse_proxy gitea-gitea-1:3000` +- Funciona correctamente + +## Solución a probar + +1. Crear una red específica para econ (ej: `econ-network`) +2. Conectar los 3 contenedores a esa red +3. Actualizar Caddy para usar los nombres de contenedor desde esa red + +O alternativamente: +- Usar `extra_hosts` en docker-compose para agregar el host al contenedor de Caddy +- Usar IP estática en la red de Caddy + +## Para probar inmediatamente + +Desde la VPS (fuera de contenedores): +- `curl http://localhost:3002` funciona +- `curl http://localhost:8080/health` funciona + +El problema es exclusivamente la comunicación Caddy → econ-containers.