Compare commits
No commits in common. "develop" and "master" have entirely different histories.
|
|
@ -1,30 +0,0 @@
|
||||||
---
|
|
||||||
name: ponytail-review
|
|
||||||
description: >
|
|
||||||
Code review focused exclusively on over-engineering. Finds what to delete:
|
|
||||||
reinvented standard library, unneeded dependencies, speculative abstractions,
|
|
||||||
and dead flexibility.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ponytail Review
|
|
||||||
|
|
||||||
Review the current diff for unnecessary complexity only. Do not review
|
|
||||||
correctness, security, performance, or style.
|
|
||||||
|
|
||||||
Report one line per finding using:
|
|
||||||
|
|
||||||
`<file>:L<line>: <tag> <what to cut>. <replacement>.`
|
|
||||||
|
|
||||||
Allowed tags:
|
|
||||||
|
|
||||||
- `delete`: dead code or speculative feature; replacement is nothing.
|
|
||||||
- `stdlib`: hand-rolled functionality provided by the standard library.
|
|
||||||
- `native`: code or dependency replaceable by a platform feature.
|
|
||||||
- `yagni`: abstraction with one implementation or one caller.
|
|
||||||
- `shrink`: same logic expressed with fewer lines.
|
|
||||||
|
|
||||||
End with `net: -<N> lines possible.` If there is nothing to cut, report
|
|
||||||
`Lean already. Ship.`
|
|
||||||
|
|
||||||
The review does not apply fixes. A smoke test is the minimum acceptable
|
|
||||||
validation and must not be flagged for deletion.
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
# Guía de Despliegue y Puesta en Producción: n8n + Gemini Gratuito + Cloudflare
|
|
||||||
|
|
||||||
Esta guía contiene los pasos exactos para tener tu agente conversacional funcionando en producción 100% gratis.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 1: Obtener la API Key Gratuita de Google Gemini
|
|
||||||
1. Ingresa en [Google AI Studio](https://aistudio.google.com/).
|
|
||||||
2. Inicia sesión con tu cuenta de Google.
|
|
||||||
3. Haz clic en **"Get API key"** -> **"Create API key in new project"**.
|
|
||||||
4. Copia tu API key generada.
|
|
||||||
- *Nota*: La capa gratuita de Gemini 2.0 Flash / 1.5 Flash incluye **15 RPM (peticiones por minuto)** y **1,500 peticiones diarias gratis**, con 1,000,000 de tokens de contexto, lo cual cubre de sobra el tráfico de tu portafolio.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 2: Despliegue de n8n en Oracle Cloud Always Free (o Localmente)
|
|
||||||
|
|
||||||
### Opción A: En tu VPS Gratuito de Oracle Cloud (Recomendado 24/7)
|
|
||||||
1. Inicia sesión en tu consola de Oracle Cloud (Always Free).
|
|
||||||
2. Crea una instancia Compute gratuita (ej. Ubuntu 24.04 ARM Ampere o AMD x86 micro).
|
|
||||||
3. Instala Docker y Docker Compose:
|
|
||||||
```bash
|
|
||||||
sudo apt update && sudo apt install -y docker.io docker-compose
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
```
|
|
||||||
4. Clona o copia la carpeta `automation/n8n/` en tu servidor.
|
|
||||||
5. Inicia el contenedor:
|
|
||||||
```bash
|
|
||||||
cd automation/n8n
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
6. n8n estará corriendo en el puerto interno `5678`.
|
|
||||||
|
|
||||||
### Opción B: Probar primero en tu máquina local
|
|
||||||
1. En tu terminal (Arch Linux):
|
|
||||||
```bash
|
|
||||||
cd /home/bryan/Projects/PORTFOLIO/automation/n8n
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
2. Abre tu navegador en `http://localhost:5678`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 3: Conectar Cloudflare Tunnel (SSL Gratuito y Sin Puertos Abiertos)
|
|
||||||
Dado que tu dominio y frontend ya están en Cloudflare:
|
|
||||||
1. Ve a [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/) -> **Networks** -> **Tunnels**.
|
|
||||||
2. Haz clic en **"Create a Tunnel"** (nombre: `portfolio-n8n`).
|
|
||||||
3. Selecciona **Docker** y copia el token provisto (`eyJh...`).
|
|
||||||
4. En el archivo `automation/n8n/.env` (o directamente en tu servidor):
|
|
||||||
```env
|
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=eyJh...tu_token_aqui...
|
|
||||||
N8N_PUBLIC_WEBHOOK_URL=https://chat-api.tudominio.com/
|
|
||||||
```
|
|
||||||
5. En la pestaña **Public Hostname** del túnel en Cloudflare:
|
|
||||||
- **Subdomain**: `chat-api` (o `n8n`)
|
|
||||||
- **Domain**: Tu dominio gestionado en Cloudflare (ej. `bryanbano.com`)
|
|
||||||
- **Service**: `HTTP` -> `n8n:5678` (o `localhost:5678` si ejecutas cloudflared como servicio).
|
|
||||||
6. ¡Listo! Tu n8n tendrá URL pública segura `https://chat-api.tudominio.com` con HTTPS y protección anti-DDoS sin tocar puertos de router.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 4: Importar el Flujo de Trabajo en n8n
|
|
||||||
1. Abre tu panel de n8n (`http://localhost:5678` o tu subdominio de Cloudflare).
|
|
||||||
2. Crea tu cuenta de administrador de n8n en el primer inicio.
|
|
||||||
3. En el menú de la izquierda, haz clic en **"Workflows"** -> botón de **"..."** (arriba a la derecha) -> **"Import from File"**.
|
|
||||||
4. Selecciona el archivo `portfolio-chat-workflow.json`.
|
|
||||||
5. En el nodo **"Google Gemini Chat Model"**:
|
|
||||||
- Haz clic en **Credentials** -> **Create New**.
|
|
||||||
- Pega tu API Key obtenida en el Paso 1.
|
|
||||||
6. Haz clic en **Save** y enciende el interruptor **"Active"** (arriba a la derecha).
|
|
||||||
7. La URL de tu webhook será:
|
|
||||||
`https://chat-api.tudominio.com/webhook/portfolio-chat` (o `http://localhost:5678/webhook/portfolio-chat` en local).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 5: Probar el Webhook desde la Terminal
|
|
||||||
Puedes verificar que n8n responde correctamente antes de probar el frontend ejecutando:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:5678/webhook/portfolio-chat \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"message": "¿Cuál es tu stack principal Bryan?", "sessionId": "test-123"}'
|
|
||||||
```
|
|
||||||
Respuesta esperada:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"reply": "Hola. Mi stack principal es .NET 8/9 con Clean Architecture y CQRS en backend, y Angular con Signals en frontend...",
|
|
||||||
"sessionId": "test-123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
# BASE DE CONOCIMIENTO: BRYAN BAÑO (FULLSTACK SOFTWARE ENGINEER)
|
|
||||||
|
|
||||||
## 1. Identidad y Perfil Profesional
|
|
||||||
- **Nombre**: Bryan Baño
|
|
||||||
- **Ubicación**: Quito, Ecuador (Zona horaria: America/Guayaquil / UTC-5)
|
|
||||||
- **Título**: Fullstack Software Engineer | Especialista en .NET Clean Architecture & Angular Signals
|
|
||||||
- **Formación Académica**: Egresado de Ingeniería en Computación de la Escuela Politécnica Nacional (EPN), Quito, Ecuador.
|
|
||||||
- **Disponibilidad**: Abierto a nuevos retos profesionales, proyectos de consultoría, desarrollo de software a medida y posiciones Fullstack / Backend / Frontend de alto impacto.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Filosofía de Ingeniería y Arquitectura
|
|
||||||
- **Clean Architecture (Arquitectura Limpia / Hexagonal)**:
|
|
||||||
- Desacoplamiento estricto de capas: Dominio puro (independiente de frameworks), Aplicación (Casos de uso / CQRS), Infraestructura (Adaptadores externos, BD, APIs) y Presentación.
|
|
||||||
- Inversión de Dependencias (DIP) y puertos & adaptadores.
|
|
||||||
- **Filosofía Ponytail (Simplicidad & Rendimiento)**:
|
|
||||||
- Rechazo a la sobreingeniería: priorizar la librería estándar, APIs nativas del navegador (Canvas 2D, Web Audio API, IntersectionObserver) y patrones limpios antes de instalar librerías pesadas de terceros.
|
|
||||||
- Código mantenible, testeable y optimizado para 60 FPS en web.
|
|
||||||
- **Estándares & Resiliencia**:
|
|
||||||
- Manejo de errores uniforme con RFC 7807 (ProblemDetails).
|
|
||||||
- Tipado estricto extremo, inmutabilidad y DTOs bien definidos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Stack Tecnológico Principal
|
|
||||||
- **Backend**:
|
|
||||||
- .NET 8 / 9 (C#)
|
|
||||||
- MediatR & Patrón CQRS (Command Query Responsibility Segregation)
|
|
||||||
- Entity Framework Core (EF Core) con optimización de queries y migraciones
|
|
||||||
- FluentValidation para validación declarativa de casos de uso
|
|
||||||
- RESTful APIs seguras, autenticación JWT y autorización basada en roles (RBAC)
|
|
||||||
- **Frontend**:
|
|
||||||
- Angular (versiones modernas 18, 19, 20)
|
|
||||||
- Angular Signals reactivos nativos (`signal`, `computed`, `effect`)
|
|
||||||
- Estrategia de detección de cambios `ChangeDetectionStrategy.OnPush`
|
|
||||||
- Sintaxis moderna de control de flujo (`@if`, `@for`, `@switch`)
|
|
||||||
- HTML5 Canvas 2D acelerado, CSS moderno, diseño Cyber-Zen / Dark Mode
|
|
||||||
- **Bases de Datos & Almacenamiento**:
|
|
||||||
- MySQL / MariaDB (indexación, optimización de queries relacionales)
|
|
||||||
- PostgreSQL
|
|
||||||
- Redis (caching y sesiones)
|
|
||||||
- **DevOps, Cloud & Entorno**:
|
|
||||||
- Docker & Docker Compose para contenerización consistente
|
|
||||||
- Cloudflare (Pages, Workers, Tunnels, DNS, WAF)
|
|
||||||
- AWS (EC2, S3, RDS)
|
|
||||||
- Sistema operativo de desarrollo diario: Arch Linux con Fish Shell
|
|
||||||
- Automatización con n8n, GitHub Actions y herramientas de IA
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Certificaciones y Seguridad
|
|
||||||
- **EC-Council**: NDE (Network Defense Essentials).
|
|
||||||
- **Cisco Networking Academy**: Redes y Endpoint Security.
|
|
||||||
- Buenas prácticas de seguridad en la nube, hardening de contenedores y protección de APIs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Proyectos Destacados del Portafolio
|
|
||||||
1. **Sistema de Auditoría & Acreditación CACES**:
|
|
||||||
- Plataforma robusta desarrollada para gestión y validación de evidencias académicas para acreditación universitaria.
|
|
||||||
- Arquitectura limpia en .NET, control de acceso granular y trazabilidad documental.
|
|
||||||
2. **Simulador de Respuesta a Incidentes Cloud (Interactive Security Simulator)**:
|
|
||||||
- Módulo interactivo dentro del portafolio que simula ataques en tiempo real y muestra la mitigación mediante arquitecturas serverless y seguridad en la nube.
|
|
||||||
3. **Portafolio Profesional Interactivo (Cyber-Zen & Retro Computing)**:
|
|
||||||
- Aplicación web desarrollada con Angular 20, Clean Architecture, Signals, OnPush.
|
|
||||||
- Incluye experiencia 3D en scroll (laptop desarmable con Three.js), terminal interactiva Linux, mini-sistema operativo retro (ZenOS) con minijuegos en Canvas 2D y probador de API en vivo.
|
|
||||||
4. **Agente Conversacional con n8n & Gemini**:
|
|
||||||
- Chatbot inteligente integrado que responde dudas técnicas y filtra oportunidades laborales en tiempo real.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Canales de Contacto Oficiales
|
|
||||||
- **Email**: Disponible mediante el formulario de contacto del portafolio.
|
|
||||||
- **LinkedIn**: Perfil profesional de Bryan Baño.
|
|
||||||
- **GitHub**: Repositorios de código y proyectos de código abierto.
|
|
||||||
- **Ubicación**: Quito, Ecuador (remoto a nivel global).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Instrucciones y Directrices para el Asistente de IA
|
|
||||||
- **Rol**: Eres el asistente virtual oficial de Bryan Baño en su portafolio web.
|
|
||||||
- **Tono**: Profesional, empático, técnico, conciso y elegante.
|
|
||||||
- **Idioma**: Responde siempre en el mismo idioma en el que te escribe el usuario (Español o Inglés).
|
|
||||||
- **Extensión**: Respuestas breves y precisas (máximo 2-3 párrafos cortos o viñetas claras). No abrumes con textos interminables.
|
|
||||||
- **Exactitud**: Limítate a los datos provistos en esta base de conocimiento. Si te preguntan algo que no conoces de Bryan, sugiere amablemente que le escriban directamente a través de la sección de Contacto del portafolio.
|
|
||||||
- **Llamado a la acción (CTA)**: Si el usuario muestra interés en contratarlo o colaborar, anímalo a dejar un mensaje en el formulario de contacto o conectar por LinkedIn.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
n8n:
|
|
||||||
image: docker.n8n.io/n8nio/n8n:latest
|
|
||||||
container_name: portfolio_n8n
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "5678:5678"
|
|
||||||
environment:
|
|
||||||
- N8N_HOST=0.0.0.0
|
|
||||||
- N8N_PORT=5678
|
|
||||||
- N8N_PROTOCOL=http
|
|
||||||
- WEBHOOK_URL=${N8N_PUBLIC_WEBHOOK_URL:-http://localhost:5678/}
|
|
||||||
- GENERIC_TIMEZONE=America/Guayaquil
|
|
||||||
- N8N_DEFAULT_BINARY_DATA_MODE=filesystem
|
|
||||||
- EXECUTIONS_DATA_PRUNE=true
|
|
||||||
- EXECUTIONS_DATA_MAX_AGE=168
|
|
||||||
volumes:
|
|
||||||
- n8n_data:/home/node/.n8n
|
|
||||||
|
|
||||||
# Cloudflare Tunnel: Expone n8n a internet de forma segura, con HTTPS y sin abrir puertos en el router/firewall
|
|
||||||
# Para activarlo: obtén el token de tu túnel en el panel de Cloudflare Zero Trust y colócalo en el archivo .env
|
|
||||||
cloudflared:
|
|
||||||
image: cloudflare/cloudflared:latest
|
|
||||||
container_name: portfolio_tunnel
|
|
||||||
restart: always
|
|
||||||
command: tunnel run
|
|
||||||
environment:
|
|
||||||
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN:-}
|
|
||||||
depends_on:
|
|
||||||
- n8n
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
n8n_data:
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Portfolio Bryan - AI Chatbot",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"httpMethod": "POST",
|
|
||||||
"path": "portfolio-chat",
|
|
||||||
"responseMode": "responseNode",
|
|
||||||
"options": {
|
|
||||||
"allowedOrigins": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
220,
|
|
||||||
340
|
|
||||||
],
|
|
||||||
"id": "webhook-trigger",
|
|
||||||
"name": "Webhook - Incoming Chat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "// Sanitización y validación de entrada para proteger cuota de API y prevenir abusos\nconst body = $input.item.json.body || {};\nconst rawMessage = typeof body.message === 'string' ? body.message.trim() : '';\nconst sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId.slice(0, 64) : 'anon-' + Date.now();\n\nif (!rawMessage) {\n return [{\n json: {\n isValid: false,\n errorMessage: 'El mensaje no puede estar vacío.',\n sessionId\n }\n }];\n}\n\n// Limitar a máximo 400 caracteres para cuidar tokens gratuitos\nconst cleanMessage = rawMessage.slice(0, 400).replace(/<[^>]*>?/gm, '');\n\nreturn [{\n json: {\n isValid: true,\n cleanMessage,\n sessionId\n }\n}];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
460,
|
|
||||||
340
|
|
||||||
],
|
|
||||||
"id": "code-sanitizer",
|
|
||||||
"name": "Sanitize & Validate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "strict"
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"id": "cond-is-valid",
|
|
||||||
"leftValue": "={{ $json.isValid }}",
|
|
||||||
"rightValue": true,
|
|
||||||
"operator": {
|
|
||||||
"type": "boolean",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
680,
|
|
||||||
340
|
|
||||||
],
|
|
||||||
"id": "if-valid-input",
|
|
||||||
"name": "Is Input Valid?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"promptType": "define",
|
|
||||||
"text": "={{ $('Sanitize & Validate').item.json.cleanMessage }}",
|
|
||||||
"options": {
|
|
||||||
"systemMessage": "=Eres el asistente virtual oficial de Bryan Baño en su portafolio web personal.\n\n### CONTEXTO DE BRYAN BAÑO:\n- Ingeniero en Computación (EPN, Quito - Ecuador).\n- Especialidad: .NET 8/9 Clean Architecture (CQRS MediatR, EF Core MySQL) y Angular Signals (OnPush, Canvas 2D).\n- Filosofía de Software: Ponytail (Simplicidad extrema, YAGNI, cero sobreingeniería, Web Platform nativa, 60 FPS).\n- Proyectos Destacados: Auditoría CACES (.NET), Incident Simulator Cloud Security (Serverless), Portafolio Interactivo Cyber-Zen.\n- Certificaciones: EC-Council Network Defense Essentials (NDE), Cisco Networking Academy.\n- Ubicación: Quito, Ecuador. Disponible para proyectos y contrataciones remotas globales.\n\n### REGLAS DE RESPUESTA:\n1. Responde siempre en el MISMO idioma del usuario (Español o Inglés).\n2. Sé técnico, conciso, profesional y cálido (máximo 2 a 3 párrafos o viñetas breves).\n3. No inventes experiencia que no esté en este contexto.\n4. Si el visitante desea contactar a Bryan o contratarlo, indícale amablemente que use el formulario de contacto del portafolio o su perfil de LinkedIn."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
|
||||||
"typeVersion": 1.7,
|
|
||||||
"position": [
|
|
||||||
920,
|
|
||||||
240
|
|
||||||
],
|
|
||||||
"id": "ai-agent-bryan",
|
|
||||||
"name": "AI Agent - Bryan Assistant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"modelName": "models/gemini-2.0-flash",
|
|
||||||
"options": {
|
|
||||||
"temperature": 0.4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
880,
|
|
||||||
480
|
|
||||||
],
|
|
||||||
"id": "gemini-model",
|
|
||||||
"name": "Google Gemini Chat Model",
|
|
||||||
"credentials": {
|
|
||||||
"googleGeminiApi": {
|
|
||||||
"id": "gemini-free-cred",
|
|
||||||
"name": "Google Gemini Free API"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"sessionKey": "={{ $('Sanitize & Validate').item.json.sessionId }}",
|
|
||||||
"contextWindowLength": 8
|
|
||||||
},
|
|
||||||
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
|
||||||
"typeVersion": 1.3,
|
|
||||||
"position": [
|
|
||||||
1040,
|
|
||||||
480
|
|
||||||
],
|
|
||||||
"id": "window-memory",
|
|
||||||
"name": "Window Buffer Memory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={\n \"reply\": {{ JSON.stringify($json.output) }},\n \"sessionId\": {{ JSON.stringify($('Sanitize & Validate').item.json.sessionId) }}\n}",
|
|
||||||
"options": {
|
|
||||||
"responseCode": 200,
|
|
||||||
"responseHeaders": {
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Origin",
|
|
||||||
"value": "*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Methods",
|
|
||||||
"value": "POST, OPTIONS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Headers",
|
|
||||||
"value": "Content-Type"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.1,
|
|
||||||
"position": [
|
|
||||||
1220,
|
|
||||||
240
|
|
||||||
],
|
|
||||||
"id": "respond-success",
|
|
||||||
"name": "Respond - Success"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={\n \"reply\": {{ JSON.stringify($json.errorMessage || 'Mensaje no válido.') }},\n \"sessionId\": {{ JSON.stringify($json.sessionId) }}\n}",
|
|
||||||
"options": {
|
|
||||||
"responseCode": 400,
|
|
||||||
"responseHeaders": {
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Origin",
|
|
||||||
"value": "*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.1,
|
|
||||||
"position": [
|
|
||||||
1220,
|
|
||||||
420
|
|
||||||
],
|
|
||||||
"id": "respond-error",
|
|
||||||
"name": "Respond - Error"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"connections": {
|
|
||||||
"Webhook - Incoming Chat": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Sanitize & Validate",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Sanitize & Validate": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Is Input Valid?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Is Input Valid?": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI Agent - Bryan Assistant",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond - Error",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"AI Agent - Bryan Assistant": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond - Success",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Google Gemini Chat Model": {
|
|
||||||
"ai_languageModel": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI Agent - Bryan Assistant",
|
|
||||||
"type": "ai_languageModel",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Window Buffer Memory": {
|
|
||||||
"ai_memory": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "AI Agent - Bryan Assistant",
|
|
||||||
"type": "ai_memory",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -118,7 +118,24 @@ CREATE TABLE IF NOT EXISTS `project_skills` (
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- ------------------------------------------------------------------------------
|
-- ------------------------------------------------------------------------------
|
||||||
-- 6. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos)
|
-- 6. TABLA: retro_scores (Tabla de Puntuaciones de Minijuegos Retro)
|
||||||
|
-- Justificación: Soporta la interactividad del videojuego retro en la ventana de
|
||||||
|
-- Retro OS. Los visitantes pueden ingresar su tag arcade de 3-4 letras (ej. 'BRY',
|
||||||
|
-- 'JDO') y competir por el Top 10 en tiempo real. Proporciona un endpoint público
|
||||||
|
-- interactivo perfecto para demostrar llamadas reales al backend .NET y MySQL.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `retro_scores` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`player_tag` VARCHAR(4) NOT NULL,
|
||||||
|
`game_code` VARCHAR(20) NOT NULL DEFAULT 'ZEN_SNAKE',
|
||||||
|
`score` INT UNSIGNED NOT NULL,
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_retro_leaderboard` (`game_code`, `score` DESC, `created_at_utc` ASC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 7. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos)
|
||||||
-- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje
|
-- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje
|
||||||
-- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`)
|
-- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`)
|
||||||
-- o el probador de API en vivo. Demuestra operaciones de escritura inmediatas
|
-- o el probador de API en vivo. Demuestra operaciones de escritura inmediatas
|
||||||
|
|
@ -136,28 +153,6 @@ CREATE TABLE IF NOT EXISTS `interactive_messages` (
|
||||||
KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC)
|
KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- ------------------------------------------------------------------------------
|
|
||||||
-- 7. TABLA: contact_inquiries (Consultas Técnicas & Solicitudes de Propuesta)
|
|
||||||
-- Justificación: Almacena leads comerciales B2B y requerimientos de clientes de
|
|
||||||
-- forma privada y segura. Separada estrictamente de 'interactive_messages' (que es
|
|
||||||
-- un libro de visitas público). Soporta filtrado por estado y auditoría por IP.
|
|
||||||
-- ------------------------------------------------------------------------------
|
|
||||||
CREATE TABLE IF NOT EXISTS `contact_inquiries` (
|
|
||||||
`id` CHAR(36) NOT NULL,
|
|
||||||
`full_name_or_company` VARCHAR(100) NOT NULL,
|
|
||||||
`email` VARCHAR(100) NOT NULL,
|
|
||||||
`phone` VARCHAR(30) NULL DEFAULT NULL,
|
|
||||||
`service_type` VARCHAR(50) NOT NULL DEFAULT 'Otro',
|
|
||||||
`project_details` TEXT NOT NULL,
|
|
||||||
`status` ENUM('New', 'Read', 'Replied', 'Archived') NOT NULL DEFAULT 'New',
|
|
||||||
`ip_address` VARCHAR(45) NULL DEFAULT NULL,
|
|
||||||
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`replied_at_utc` DATETIME NULL DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_inquiries_status_created` (`status`, `created_at_utc` DESC),
|
|
||||||
KEY `idx_inquiries_created` (`created_at_utc` DESC)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- ==============================================================================
|
-- ==============================================================================
|
||||||
-- SEED DATA INICIAL (Datos de prueba para arranque inmediato)
|
-- SEED DATA INICIAL (Datos de prueba para arranque inmediato)
|
||||||
-- ==============================================================================
|
-- ==============================================================================
|
||||||
|
|
@ -206,7 +201,15 @@ VALUES
|
||||||
('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004')
|
('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004')
|
||||||
ON DUPLICATE KEY UPDATE `project_id` = `project_id`;
|
ON DUPLICATE KEY UPDATE `project_id` = `project_id`;
|
||||||
|
|
||||||
-- 5. Mensaje de Bienvenida en el Libro de Visitas
|
-- 5. Scores de Prueba para el Minijuego Retro
|
||||||
|
INSERT INTO `retro_scores` (`id`, `player_tag`, `game_code`, `score`)
|
||||||
|
VALUES
|
||||||
|
(UUID(), 'ZEN', 'ZEN_SNAKE', 1450),
|
||||||
|
(UUID(), 'NEO', 'ZEN_SNAKE', 1120),
|
||||||
|
(UUID(), 'BRY', 'ZEN_SNAKE', 980)
|
||||||
|
ON DUPLICATE KEY UPDATE `player_tag` = `player_tag`;
|
||||||
|
|
||||||
|
-- 6. Mensaje de Bienvenida en el Libro de Visitas
|
||||||
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
|
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
|
||||||
VALUES (
|
VALUES (
|
||||||
UUID(),
|
UUID(),
|
||||||
|
|
@ -215,16 +218,3 @@ VALUES (
|
||||||
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
|
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
|
||||||
'Terminal'
|
'Terminal'
|
||||||
) ON DUPLICATE KEY UPDATE `sender_name` = `sender_name`;
|
) ON DUPLICATE KEY UPDATE `sender_name` = `sender_name`;
|
||||||
|
|
||||||
-- 6. Consulta de Contacto Inicial de Prueba
|
|
||||||
INSERT INTO `contact_inquiries` (`id`, `full_name_or_company`, `email`, `phone`, `service_type`, `project_details`, `status`)
|
|
||||||
VALUES (
|
|
||||||
'44444444-0001-0000-0000-000000000001',
|
|
||||||
'Enterprise Solutions Corp',
|
|
||||||
'contacto@enterprisesolutions.com',
|
|
||||||
'+593 99 123 4567',
|
|
||||||
'Desarrollo Backend',
|
|
||||||
'Requerimos modernización de arquitectura monolítica a microservicios con .NET 9, Clean Architecture y CQRS.',
|
|
||||||
'New'
|
|
||||||
) ON DUPLICATE KEY UPDATE `full_name_or_company` = `full_name_or_company`;
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "src/assets",
|
"input": "src/assets",
|
||||||
"output": "assets"
|
"output": "assets"
|
||||||
},
|
}
|
||||||
"src/_redirects"
|
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
|
|
@ -44,8 +43,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "15kB",
|
"maximumWarning": "8kB",
|
||||||
"maximumError": "30kB"
|
"maximumError": "16kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/* /index.html 200
|
|
||||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 659 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1 +0,0 @@
|
||||||
/* /index.html 200
|
|
||||||
|
|
@ -4,18 +4,12 @@ import { provideHttpClient } from '@angular/common/http';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { RetroScorePort } from './domain/ports/retro-score.port';
|
import { RetroScorePort } from './domain/ports/retro-score.port';
|
||||||
import { LocalStorageRetroScoreAdapter } from './infrastructure/adapters/local-storage-retro-score.adapter';
|
import { LocalStorageRetroScoreAdapter } from './infrastructure/adapters/local-storage-retro-score.adapter';
|
||||||
import { ChatPort } from './domain/ports/chat.port';
|
|
||||||
import { N8nChatAdapter } from './infrastructure/adapters/n8n-chat.adapter';
|
|
||||||
import { SecurityAgentPort } from './domain/ports/security-agent.port';
|
|
||||||
import { SecurityAgentService } from './services/security-agent.service';
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
{ provide: RetroScorePort, useClass: LocalStorageRetroScoreAdapter },
|
{ provide: RetroScorePort, useClass: LocalStorageRetroScoreAdapter }
|
||||||
{ provide: ChatPort, useClass: N8nChatAdapter },
|
|
||||||
{ provide: SecurityAgentPort, useClass: SecurityAgentService }
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,6 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
|
||||||
switchLangAria: 'Switch to Spanish',
|
switchLangAria: 'Switch to Spanish',
|
||||||
footerCopy: 'Bryan. Clean Architecture & High-Contrast Design.',
|
footerCopy: 'Bryan. Clean Architecture & High-Contrast Design.',
|
||||||
|
|
||||||
// Navbar Quick Navigation
|
|
||||||
navHome: 'Home',
|
|
||||||
navRetro: 'Retro Desktop',
|
|
||||||
navProjects: 'Projects',
|
|
||||||
navSimulator: 'Simulator',
|
|
||||||
navTerminal: 'Terminal',
|
|
||||||
navContact: 'Contact',
|
|
||||||
navAll: 'View All',
|
|
||||||
navPrevSection: 'Previous section',
|
|
||||||
navNextSection: 'Next section',
|
|
||||||
|
|
||||||
// Hero 3D
|
// Hero 3D
|
||||||
preloading: 'PRELOADING',
|
preloading: 'PRELOADING',
|
||||||
heroAriaExperience: 'ThinkPad 3D Scroll Experience',
|
heroAriaExperience: 'ThinkPad 3D Scroll Experience',
|
||||||
|
|
@ -30,45 +19,38 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
|
||||||
p1LeadTag: '01 // SOFTWARE ENGINEERING',
|
p1LeadTag: '01 // SOFTWARE ENGINEERING',
|
||||||
p1LeadName: 'Bryan Baño',
|
p1LeadName: 'Bryan Baño',
|
||||||
p1LeadRole: 'Fullstack Software Engineer',
|
p1LeadRole: 'Fullstack Software Engineer',
|
||||||
p1LeadDesc: 'Specialist in .NET Clean Architecture and Angular Signals. I build high-throughput systems with strict decoupling and fluid web experiences.',
|
p1LeadDesc: 'Specialist in .NET Clean Architecture and reactive Angular Signals. I engineer high-throughput systems, strict decoupling, and high-performance interactive web experiences.',
|
||||||
p1LeadBadge1: '.NET 9 Clean Arch',
|
p1LeadBadge1: '.NET 9 Clean Arch',
|
||||||
p1LeadBadge2: 'Angular Signals',
|
p1LeadBadge2: 'Angular Signals',
|
||||||
p1LeadBadge3: 'MySQL Performance',
|
p1LeadBadge3: 'MySQL Performance',
|
||||||
p1LeadMetric: 'HEXAGONAL ARCHITECTURE • ZERO BLOAT',
|
p1LeadMetric: 'HEXAGONAL ARCHITECTURE • ZERO BLOAT',
|
||||||
p1RightTag: 'PHILOSOPHY // CORE',
|
p1RightTag: 'PHILOSOPHY // CORE',
|
||||||
p1RightTitle: 'Simplicity & Performance',
|
p1RightTitle: 'Simplicity & Performance',
|
||||||
p1RightDesc: 'Ponytail: less dependency, more performance, and direct 60 FPS code.',
|
p1RightDesc: 'Ponytail approach (YAGNI / KISS): native platform tools before heavy packages and direct 60 FPS code.',
|
||||||
p1RightBadge: 'Native Web Platform',
|
p1RightBadge: 'Native Web Platform',
|
||||||
p2LeftTag: 'BACKEND CORE',
|
p2LeftTag: 'BACKEND CORE',
|
||||||
p2LeftTitle: '.NET 9 & MediatR CQRS',
|
p2LeftTitle: '.NET 9 & MediatR CQRS',
|
||||||
p2LeftDesc: 'CQRS, FluentValidation, and optimized transactional queries in EF Core.',
|
p2LeftDesc: 'Command Query Responsibility Segregation, FluentValidation rules, and high-throughput transactional queries via EF Core.',
|
||||||
p2LeftBadge1: 'CQRS & MediatR',
|
p2LeftBadge1: 'CQRS & MediatR',
|
||||||
p2LeftBadge2: 'Optimized EF Core',
|
p2LeftBadge2: 'Optimized EF Core',
|
||||||
p2RightTag: 'FRONTEND ENGINE',
|
p2RightTag: 'FRONTEND ENGINE',
|
||||||
p2RightTitle: 'Angular 19 & Signals',
|
p2RightTitle: 'Angular 19 & Signals',
|
||||||
p2RightDesc: 'Native Signals, OnPush rendering, and hardware-accelerated 2D animation.',
|
p2RightDesc: 'Native reactive state primitives without RxJS overhead, OnPush rendering, and GPU-accelerated 2D Canvas.',
|
||||||
p2RightBadge1: 'Reactive Signals',
|
p2RightBadge1: 'Reactive Signals',
|
||||||
p2RightBadge2: 'OnPush 60FPS',
|
p2RightBadge2: 'OnPush 60FPS',
|
||||||
p3LeftTag: '04 // STANDARDS',
|
p3LeftTag: '04 // STANDARDS',
|
||||||
p3LeftTitle: 'Patterns & Resilience',
|
p3LeftTitle: 'Patterns & Resilience',
|
||||||
p3LeftDesc: 'DIP, typed DTOs, and consistent ProblemDetails handling.',
|
p3LeftDesc: 'Dependency Inversion (DIP), strictly typed DTOs, and uniform ProblemDetails RFC 7807 error handling.',
|
||||||
p3LeftBadge1: 'Dependency Inversion',
|
p3LeftBadge1: 'Dependency Inversion',
|
||||||
p3LeftBadge2: 'RFC 7807',
|
p3LeftBadge2: 'RFC 7807',
|
||||||
p3RightTag: '05 // LIVE LAB',
|
p3RightTag: '05 // LIVE LAB',
|
||||||
p3RightTitle: 'Interactive Sandbox',
|
p3RightTitle: 'Interactive Sandbox',
|
||||||
p3RightDesc: 'Interactive Linux terminal and retro lab with classic games.',
|
p3RightDesc: 'Embedded interactive Linux terminal with live backend HTTP calls and Retro OS with classic arcade games.',
|
||||||
p3RightBadge1: 'Linux Terminal',
|
p3RightBadge1: 'Linux Terminal',
|
||||||
p3RightBadge2: 'Live .NET API',
|
p3RightBadge2: 'Live .NET API',
|
||||||
|
|
||||||
// Technical Profile
|
// Technical Profile
|
||||||
tpAboutGreeting: 'Hello, I am',
|
tpEyebrow: '01 // COMPUTER ENGINEERING & EXPERTISE',
|
||||||
tpAboutName: 'Bryan Baño',
|
|
||||||
tpAboutDescription: 'I am a software engineer focused on building thoughtful, accessible, and technically solid digital experiences. I combine clean architecture, modern frontend development, and scalable backend solutions. I work with .NET, Angular, Signals, REST APIs, and relational databases.\n\nMy approach combines engineering and design. First I understand the problem, then simplify the experience, and finally build a maintainable solution. I prioritize performance, accessibility, separation of concerns, and code quality.\n\nI also explore interfaces inspired by Unix terminals, retro systems, Cyber-Zen aesthetics, and new ways to present technical information without sacrificing readability. Every project must be functional, expressive, and easy to maintain.\n\nI currently develop solutions that connect frontend, backend, visual design, and digital interaction. I am interested in creating products that not only work reliably, but also communicate a clear and memorable identity.',
|
|
||||||
tpAboutAria: 'About Bryan Baño',
|
|
||||||
tpAboutLink: 'About me',
|
|
||||||
tpAsciiError: 'Could not load portrait.',
|
|
||||||
tpAsciiLoading: 'Loading portrait...',
|
|
||||||
tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
|
|
||||||
tpTitle: 'Technical Skills & Areas of Interest',
|
tpTitle: 'Technical Skills & Areas of Interest',
|
||||||
tpLeadStudent: '9th semester Computer Engineering student at',
|
tpLeadStudent: '9th semester Computer Engineering student at',
|
||||||
tpLeadSpecialization: 'Specialized in clean architectures with',
|
tpLeadSpecialization: 'Specialized in clean architectures with',
|
||||||
|
|
@ -89,74 +71,33 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
|
||||||
tpInterest3Desc: 'Active participation in tech outreach and community events (FLISOL, Google Developers Group).',
|
tpInterest3Desc: 'Active participation in tech outreach and community events (FLISOL, Google Developers Group).',
|
||||||
|
|
||||||
// Featured Projects
|
// Featured Projects
|
||||||
fpHeaderTag: 'PROJECTS',
|
fpHeaderTag: '01 // PROJECTS',
|
||||||
fpAriaLabel: "Bryan Baño's featured projects",
|
fpAriaLabel: "Bryan Baño's featured projects",
|
||||||
fpPlaceholderImage: 'No screenshots',
|
fpPlaceholderImage: 'Image',
|
||||||
fpAltPrefix: 'Screenshot of ',
|
fpAltPrefix: 'Screenshot of ',
|
||||||
fpCarouselPrev: 'Previous project',
|
prj1Subtitle: 'CACES Institutional Accreditation',
|
||||||
fpCarouselNext: 'Next project',
|
prj1Desc: 'Led platform development centralizing evidence upload and validation with advanced security via JWT and SharePoint.',
|
||||||
fpInDevelopment: 'In Development',
|
prj1Impact: 'Automated indicator tracking, eliminating physical paper management with complete traceability for government audits.',
|
||||||
fpNoImagesTechnical: 'Architecture & CACES Audit Spec',
|
prj2Subtitle: 'Comprehensive Workshop & Work Order Management',
|
||||||
projectDetailChallenge: 'Challenges',
|
prj2Institution: 'Automotive Sector',
|
||||||
projectDetailSolution: 'Solution',
|
prj2Desc: 'Comprehensive platform for mechanical supply inventory control, vehicle registration, and real-time work order tracking.',
|
||||||
projectDetailResults: 'Results',
|
prj2Impact: 'Optimized workshop operational flow, eliminating shortages of critical supplies and improving turnaround times with digital records.',
|
||||||
projectDetailCloseAria: 'Close project details',
|
prj3Subtitle: 'Accelerated Virtual Education',
|
||||||
|
prj3Institution: 'Academic Platform',
|
||||||
// PRJ-01: SIPLECE (No images, technical / accreditation focus)
|
prj3Desc: 'Led academic management platform development based on RESTful API architecture for high availability in remote learning.',
|
||||||
prj1Subtitle: 'CACES Accreditation & Document Governance',
|
prj3Impact: 'Centralizes academic administration and automates student tracking, eliminating manual enrollment and attendance processes.',
|
||||||
prj1Desc: 'Centralized institutional platform to upload, audit, and validate technical-academic evidence required by CACES, integrating SharePoint, JWT authentication, and role-based access control.',
|
|
||||||
prj1Impact: 'Automated institutional indicator tracking, eliminating manual paperwork and guaranteeing complete traceability for governmental audits.',
|
|
||||||
prj1Challenge: 'The accreditation process was fragmented across desynchronized spreadsheets, physical binders, and informal approvals, creating severe delays and risking critical evidence omissions.',
|
|
||||||
prj1Solution: 'Designed a decoupled Clean Architecture platform using C# .NET and Angular, integrating SharePoint for encrypted storage, JWT with RBAC, and structured multi-stage approval workflows with immutable timestamps.',
|
|
||||||
prj1Result1: 'Drastically reduced review and validation turnaround times between academic and administrative departments.',
|
|
||||||
prj1Result2: 'Achieved 100% document version control and traceability for official CACES evaluation committees.',
|
|
||||||
prj1Result3: 'Standardized institutional governance for 100+ evaluation indicators and quality metrics.',
|
|
||||||
|
|
||||||
// PRJ-02: Ammi-Online (Full academic management with images)
|
|
||||||
prj2Subtitle: 'Academic Management, Admissions, Payments & Grades',
|
|
||||||
prj2Institution: 'Higher Education / Academic Platform',
|
|
||||||
prj2Desc: 'Comprehensive web ecosystem for full student lifecycle management: online applications, automated enrollments, payment processing, and real-time grade records.',
|
|
||||||
prj2Impact: 'Unified academic and financial workflows into a single high-concurrency system, cutting enrollment wait times and guaranteeing instant payment reconciliation.',
|
|
||||||
prj2Challenge: 'Fragmented admission requests, manual bank receipt reconciliations, and spreadsheet grade records that were vulnerable to human errors and discrepancies.',
|
|
||||||
prj2Solution: 'Implemented Clean Architecture with C# .NET 8 (MediatR CQRS) and Angular Signals. Integrated decoupled modules for applications, payment gateways, accounting validation, and grade books under strict database-driven RBAC and JWT.',
|
|
||||||
prj2Result1: '100% automated enrollment and digital payment reconciliation without manual intervention.',
|
|
||||||
prj2Result2: 'Centralized real-time grade records with comprehensive teacher modification audit trails.',
|
|
||||||
prj2Result3: 'Ultra-fast and reactive user experience for thousands of concurrent students via Angular Signals and OnPush.',
|
|
||||||
|
|
||||||
// PRJ-03: HR Vacations & Leaves Module (With images)
|
|
||||||
prj3Subtitle: 'Vacation Control, FIFO Algorithm & Real-Time Legal Deeds',
|
|
||||||
prj3Institution: 'Corporate / Human Resources',
|
|
||||||
prj3Desc: 'Comprehensive, legally compliant human resources module for employee vacation tracking, FIFO period calculations, real-time PDF legal deeds, and analytical executive dashboards.',
|
|
||||||
prj3Impact: 'Fully automated the vacation and leave lifecycle, eliminating manual labor liability calculations and guaranteeing 100% legal compliance.',
|
|
||||||
prj3Challenge: 'Manual calculations vulnerable to legal disputes over seniority periods, unworked leave deductions, institutional shutdowns, and delayed legal deed generation.',
|
|
||||||
prj3Solution: 'Engineered a FIFO vacation settlement engine following Clean Architecture, featuring real-time PDF deed generation, dynamic dashboards, and 100% configurable extra days by department, incentive, and closures. Integrated dynamic leave deductions, DB-backed RBAC, and JWT security.',
|
|
||||||
prj3Result1: 'Instant generation of signed, legally compliant vacation deeds with real-time auditability.',
|
|
||||||
prj3Result2: 'Dynamic and transparent leave deductions linked directly to the corresponding vacation balance.',
|
|
||||||
prj3Result3: 'Executive analytics dashboards monitoring absenteeism and labor liability metrics for leadership.',
|
|
||||||
|
|
||||||
// PRJ-04: Academic Degree Completion System (In Development, with images)
|
|
||||||
prj4Subtitle: 'Calls, Modalities & Degree Matrices (In Development)',
|
|
||||||
prj4Institution: 'Higher Education',
|
|
||||||
prj4Desc: 'Comprehensive platform for managing university graduation processes: candidate applications, modality tracking (scientific paper, comprehensive exam, ICT projects), and degree requirement validation.',
|
|
||||||
prj4Impact: 'Standardizes institutional degree completion through configurable open calls, providing absolute transparency across students, tutors, and evaluation tribunals.',
|
|
||||||
prj4Challenge: 'Coordinating high candidate volumes with disparate prerequisites across degree modalities, manual validation of internship/community service hours, and lack of a unified evaluation channel.',
|
|
||||||
prj4Solution: 'Architected a Clean Architecture platform (.NET + Angular Signals) orchestrated around dynamic administrative calls. Features database-driven RBAC with 4 roles (Administrator, Student, Professor, and Evaluador), tracking matrices, and custom business rule validators.',
|
|
||||||
prj4Result1: 'Structured call lifecycle: opening, student application, tribunal assignment, and final defense.',
|
|
||||||
prj4Result2: 'Configurable support for scientific publications, comprehensive exams, and technological projects.',
|
|
||||||
prj4Result3: 'Hardened security via JWT, database-driven role permissions, and prerequisite validation.',
|
|
||||||
|
|
||||||
// Terminal & Workstation
|
// Terminal & Workstation
|
||||||
terminalWorkstationTag: 'INTERACTIVE WORKSTATION',
|
terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION',
|
||||||
terminalHeading: 'Arch Linux Terminal // Fish Shell',
|
terminalHeading: 'Arch Linux Terminal // Fish Shell',
|
||||||
terminalSectionDesc: 'Full-scale interactive console to query technical ecosystem, skills, and live database.',
|
terminalSectionDesc: 'Full-scale interactive console to query technical ecosystem, skills, and live database.',
|
||||||
terminalBanner: `bryan@archlinux
|
terminalBanner: ` /\\ bryan@archlinux
|
||||||
OS: Arch Linux x86_64
|
/ \\ OS: Arch Linux x86_64
|
||||||
Host: ThinkPad Z13 Gen 2
|
/\\ \\ Host: ThinkPad Z13 Gen 2
|
||||||
Kernel: 6.10.7-arch1-1
|
/ \\ Kernel: 6.10.7-arch1-1
|
||||||
Shell: fish 3.7.0
|
/ ,, \\ Shell: fish 3.7.0
|
||||||
Terminal: zen-alacritty
|
/ | | -\\ Terminal: zen-alacritty
|
||||||
|
/_-'' ''-_\\ Type "help" to start.`,
|
||||||
Type "help" to start.`,
|
|
||||||
terminalHint: 'Welcome to the interactive Arch Linux / Fish Shell environment.\nType "help" to view available commands.',
|
terminalHint: 'Welcome to the interactive Arch Linux / Fish Shell environment.\nType "help" to view available commands.',
|
||||||
terminalHelpContent: `Arch Linux (x86_64) — fish, version 3.7.0
|
terminalHelpContent: `Arch Linux (x86_64) — fish, version 3.7.0
|
||||||
Available commands for this interactive session:
|
Available commands for this interactive session:
|
||||||
|
|
@ -265,70 +206,27 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d
|
||||||
retroSetSound: 'Web Audio API Sound',
|
retroSetSound: 'Web Audio API Sound',
|
||||||
retroSetSoundDesc: 'Synthetic mechanical and arcade effects',
|
retroSetSoundDesc: 'Synthetic mechanical and arcade effects',
|
||||||
retroScoresHeader: '★ ARCADE LEADERBOARD',
|
retroScoresHeader: '★ ARCADE LEADERBOARD',
|
||||||
retroScoresTitle: 'MINESWEEPER // TOP 10',
|
retroScoresTitle: 'ZEN SNAKE // TOP 10',
|
||||||
retroScoresRefresh: 'REFRESH',
|
retroScoresRefresh: 'REFRESH',
|
||||||
retroScoresRank: '#',
|
retroScoresRank: '#',
|
||||||
retroScoresTag: 'TAG',
|
retroScoresTag: 'TAG',
|
||||||
retroScoresScore: 'TIME (S)',
|
retroScoresScore: 'SCORE',
|
||||||
retroScoresDate: 'DATE',
|
retroScoresDate: 'DATE',
|
||||||
retroScoresEmpty: 'No scores recorded',
|
retroScoresEmpty: 'No scores recorded',
|
||||||
retroGameScore: 'TIME:',
|
retroGameScore: 'SCORE:',
|
||||||
retroGameHigh: 'BEST:',
|
retroGameHigh: 'BEST:',
|
||||||
retroGameStart: '▶ Start',
|
retroGameStart: '▶ Start',
|
||||||
retroGameRestart: 'Restart',
|
retroGameRestart: 'Restart',
|
||||||
retroGamePause: 'Pause',
|
retroGamePause: 'Pause',
|
||||||
retroGameResume: 'Resume',
|
retroGameResume: 'Resume',
|
||||||
retroGameBanner: 'Click any tile to start playing',
|
retroGameBanner: 'Press any Arrow key or click to start',
|
||||||
retroGameOver: 'BOOM! GAME OVER',
|
retroGameOver: 'GAME OVER!',
|
||||||
retroGameScoreLabel: 'Time:',
|
retroGameScoreLabel: 'Score:',
|
||||||
retroGameSave: 'Save',
|
retroGameSave: 'Save',
|
||||||
retroGameSaved: '✓ Score saved',
|
retroGameSaved: '✓ Score saved',
|
||||||
retroGameWin: 'VICTORY! MINES CLEARED',
|
retroGamePlayAgain: '↺ Play Again',
|
||||||
retroGameMines: 'Mines:',
|
retroGameUp: 'Up',
|
||||||
retroGameFlagMode: 'Flag Mode',
|
retroGameLeft: 'Left',
|
||||||
retroGameDigMode: 'Dig Mode',
|
retroGameDown: 'Down',
|
||||||
|
retroGameRight: 'Right'
|
||||||
// Cloud Incident Simulator
|
|
||||||
simTag: '03 // CLOUD ARCHITECTURE & SERVERLESS SECURITY',
|
|
||||||
simHeading: 'Real-Time Incident Response Simulator',
|
|
||||||
simSubheading: 'Interactive visualization of an automated active-defense architecture in AWS. Experience how an unauthorized IAM access event is detected, mitigated by a Lambda function, and dispatched via SES in under 180 ms without manual intervention.',
|
|
||||||
simModeStory: 'Explained View (Business)',
|
|
||||||
simModeTech: 'SecOps Telemetry View (Dev)',
|
|
||||||
simEmailLabel: 'Email to receive the forensic report:',
|
|
||||||
simEmailPlaceholder: 'your-email@company.com',
|
|
||||||
simEmailHint: 'Enter your email to receive cryptographic audit details of the contained incident.',
|
|
||||||
simBtnSimulate: 'Simulate Attack & Defense',
|
|
||||||
simBtnSimulating: 'Containing Threat in Real-Time...',
|
|
||||||
simBtnReset: 'Reset Simulation',
|
|
||||||
simStep1Title: 'Intrusion Attempt',
|
|
||||||
simStep1Desc: 'An attacker attempts to steal administrative credentials from an untrusted IP.',
|
|
||||||
simStep2Title: 'Instant Detection',
|
|
||||||
simStep2Desc: 'The security perimeter flags the anomaly and triggers active defense in under 50 milliseconds.',
|
|
||||||
simStep3Title: 'Automated Lockdown',
|
|
||||||
simStep3Desc: 'A serverless function revokes active sessions and seals the perimeter without human intervention.',
|
|
||||||
simStep4Title: 'Forensic Notification',
|
|
||||||
simStep4Desc: 'Forensic evidence is compiled and the security certificate is dispatched by email.',
|
|
||||||
simThreatNodeTitle: 'Threat Vector',
|
|
||||||
simThreatNodeSub: 'Anomalous iam:CreateAccessKey',
|
|
||||||
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
|
|
||||||
simCloudTrailNodeSub: 'Event ingestion & pattern match',
|
|
||||||
simLambdaNodeTitle: 'AWS Lambda (Remediation)',
|
|
||||||
simLambdaNodeSub: 'Session revocation & DenyAll',
|
|
||||||
simSesNodeTitle: 'Amazon SES / SecOps Alert',
|
|
||||||
simSesNodeSub: 'Incident report dispatched',
|
|
||||||
simConsoleTitle: 'SecOps Telemetry Console // Live Stream',
|
|
||||||
simMetricLatency: 'Containment Latency',
|
|
||||||
simMetricIntervention: 'Manual Intervention',
|
|
||||||
simMetricArchitecture: 'Serverless Architecture',
|
|
||||||
simStatusIdle: 'SYSTEM NOMINAL // LISTENING FOR EVENTS',
|
|
||||||
simStatusActive: 'THREAT DETECTED // EXECUTING REMEDIATION',
|
|
||||||
simStatusContained: 'THREAT CONTAINED // SECURE STATE RESTORED',
|
|
||||||
simReportTitle: 'FORENSIC AUDIT CERTIFICATE // INCIDENT MITIGATED',
|
|
||||||
simReportTargetEmail: 'Audit Recipient:',
|
|
||||||
simReportStatusSecure: 'STATUS: 100% SECURE // ZERO LEAKS',
|
|
||||||
simReportTimeVsAvg: '184 ms (Vs. 4.2 hours industry average)',
|
|
||||||
simReportCredentialsCompromised: '0 credentials compromised',
|
|
||||||
simReportFinancialLoss: '$0.00 USD (Financial damage avoided)',
|
|
||||||
simReportDownloadBtn: 'Download Forensic Report (Demo PDF)',
|
|
||||||
simReportDemoBadge: 'LIVE AUTOGENERATED CERTIFICATE'
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,6 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
|
||||||
switchLangAria: 'Cambiar a Inglés',
|
switchLangAria: 'Cambiar a Inglés',
|
||||||
footerCopy: 'Bryan. Clean Architecture & Diseño de Alto Contraste.',
|
footerCopy: 'Bryan. Clean Architecture & Diseño de Alto Contraste.',
|
||||||
|
|
||||||
// Navbar Quick Navigation
|
|
||||||
navHome: 'Inicio',
|
|
||||||
navRetro: 'Desktop Retro',
|
|
||||||
navProjects: 'Proyectos',
|
|
||||||
navSimulator: 'Simulador',
|
|
||||||
navTerminal: 'Terminal',
|
|
||||||
navContact: 'Contacto',
|
|
||||||
navAll: 'Ver Todo',
|
|
||||||
navPrevSection: 'Sección anterior',
|
|
||||||
navNextSection: 'Siguiente sección',
|
|
||||||
|
|
||||||
// Hero 3D
|
// Hero 3D
|
||||||
preloading: 'PRECARGANDO',
|
preloading: 'PRECARGANDO',
|
||||||
heroAriaExperience: 'Experiencia 3D ThinkPad en Scroll',
|
heroAriaExperience: 'Experiencia 3D ThinkPad en Scroll',
|
||||||
|
|
@ -26,48 +15,41 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
|
||||||
heroAriaFrontend: 'Frontend Reactivo',
|
heroAriaFrontend: 'Frontend Reactivo',
|
||||||
heroAriaStandards: 'Estándares Técnicos',
|
heroAriaStandards: 'Estándares Técnicos',
|
||||||
heroAriaInteractive: 'Entorno Interactivo',
|
heroAriaInteractive: 'Entorno Interactivo',
|
||||||
p1LeadStatus: 'DISPONIBLE // FULLSTACK',
|
p1LeadStatus: '● DISPONIBLE // FULLSTACK',
|
||||||
p1LeadTag: 'INGENIERÍA DE SOFTWARE',
|
p1LeadTag: '01 // INGENIERÍA DE SOFTWARE',
|
||||||
p1LeadName: 'Bryan Baño',
|
p1LeadName: 'Bryan Baño',
|
||||||
p1LeadRole: 'Fullstack Software Engineer',
|
p1LeadRole: 'Fullstack Software Engineer',
|
||||||
p1LeadDesc: 'Especialista en .NET Clean Architecture y Angular Signals. Diseño sistemas de alta concurrencia con desacoplamiento estricto y experiencias web fluidas.',
|
p1LeadDesc: 'Especialista en .NET Clean Architecture y Angular Signals reactivo. Diseño sistemas empresariales de alta concurrencia, desacoplamiento estricto y experiencias web de máxima fluidez.',
|
||||||
p1LeadBadge1: '.NET 9 Clean Arch',
|
p1LeadBadge1: '.NET 9 Clean Arch',
|
||||||
p1LeadBadge2: 'Angular Signals',
|
p1LeadBadge2: 'Angular Signals',
|
||||||
p1LeadBadge3: 'MySQL Performance',
|
p1LeadBadge3: 'MySQL Performance',
|
||||||
p1LeadMetric: 'ARQUITECTURA HEXAGONAL • CERO BLOAT',
|
p1LeadMetric: 'ARQUITECTURA HEXAGONAL • CERO SOBREINGENIERÍA',
|
||||||
p1RightTag: 'FILOSOFÍA // CORE',
|
p1RightTag: 'FILOSOFÍA // CORE',
|
||||||
p1RightTitle: 'Simplicidad & Rendimiento',
|
p1RightTitle: 'Simplicidad & Rendimiento',
|
||||||
p1RightDesc: 'Ponytail: menos dependencia, más rendimiento y código directo a 60 FPS.',
|
p1RightDesc: 'Enfoque Ponytail (YAGNI / KISS): herramientas nativas antes de dependencias pesadas y código directo a 60 FPS.',
|
||||||
p1RightBadge: 'Web Platform Nativa',
|
p1RightBadge: 'Web Platform Nativa',
|
||||||
p2LeftTag: 'BACKEND CORE',
|
p2LeftTag: 'BACKEND CORE',
|
||||||
p2LeftTitle: '.NET 9 & MediatR CQRS',
|
p2LeftTitle: '.NET 9 & MediatR CQRS',
|
||||||
p2LeftDesc: 'CQRS, FluentValidation y consultas transaccionales optimizadas con EF Core.',
|
p2LeftDesc: 'Segregación de comandos y consultas, validación con FluentValidation y mapeo transaccional con EF Core.',
|
||||||
p2LeftBadge1: 'CQRS & MediatR',
|
p2LeftBadge1: 'CQRS & MediatR',
|
||||||
p2LeftBadge2: 'EF Core Optimizado',
|
p2LeftBadge2: 'EF Core Optimizado',
|
||||||
p2RightTag: 'FRONTEND ENGINE',
|
p2RightTag: 'FRONTEND ENGINE',
|
||||||
p2RightTitle: 'Angular 19 & Signals',
|
p2RightTitle: 'Angular 19 & Signals',
|
||||||
p2RightDesc: 'Signals nativos, OnPush y animaciones 2D aceleradas por hardware.',
|
p2RightDesc: 'Gestión de estado nativa con Signals, renderizado OnPush y animaciones por hardware en Canvas 2D.',
|
||||||
p2RightBadge1: 'Signals Reactivos',
|
p2RightBadge1: 'Signals Reactivos',
|
||||||
p2RightBadge2: 'OnPush 60FPS',
|
p2RightBadge2: 'OnPush 60FPS',
|
||||||
p3LeftTag: 'ESTÁNDARES',
|
p3LeftTag: 'ESTÁNDARES',
|
||||||
p3LeftTitle: 'Patrones & Resiliencia',
|
p3LeftTitle: 'Patrones & Resiliencia',
|
||||||
p3LeftDesc: 'DIP, DTOs tipados y errores uniformes con ProblemDetails.',
|
p3LeftDesc: 'Inversión de dependencias (DIP), DTOs estrictamente tipados y manejo uniforme de errores ProblemDetails RFC 7807.',
|
||||||
p3LeftBadge1: 'Inversión Dependencias',
|
p3LeftBadge1: 'Inversión Dependencias',
|
||||||
p3LeftBadge2: 'RFC 7807',
|
p3LeftBadge2: 'RFC 7807',
|
||||||
p3RightTag: 'LABORATORIO EN VIVO',
|
p3RightTag: 'LABORATORIO EN VIVO',
|
||||||
p3RightTitle: 'Entorno Interactivo',
|
p3RightTitle: 'Entorno Interactivo',
|
||||||
p3RightDesc: 'Terminal Linux interactiva y laboratorio retro con juegos clásicos.',
|
p3RightDesc: 'Consola Linux interactiva con llamadas HTTP reales al backend y mini-sistema operativo retro con videojuegos clásicos.',
|
||||||
p3RightBadge1: 'Terminal Linux',
|
p3RightBadge1: 'Terminal Linux',
|
||||||
p3RightBadge2: 'Live .NET API',
|
p3RightBadge2: 'Live .NET API',
|
||||||
|
|
||||||
// Technical Profile
|
// Technical Profile
|
||||||
tpAboutGreeting: 'Hola, soy',
|
|
||||||
tpAboutName: 'Bryan Baño',
|
|
||||||
tpAboutDescription: 'Egresado de Ingeniería en Computación (EPN) | Desarrollador Full Stack apasionado por crear arquitecturas eficientes. Me enfoco en construir experiencias digitales claras, accesibles y técnicamente sólidas. Me interesa combinar arquitectura limpia, desarrollo frontend moderno y soluciones backend escalables. Trabajo con .NET, Angular, Signals, APIs REST y bases de datos relacionales.\n\nMi proceso combina ingeniería y diseño. Primero entiendo el problema, después simplifico la experiencia y finalmente construyo una solución mantenible. Priorizo el rendimiento, la accesibilidad, la separación de responsabilidades y la calidad del código.\n\nTambién exploro interfaces inspiradas en terminales Unix, sistemas retro, estética Cyber-Zen y nuevas formas de presentar información técnica sin sacrificar legibilidad. Cada proyecto debe ser funcional, expresivo y fácil de mantener.\n\nActualmente desarrollo soluciones que conectan frontend, backend, diseño visual e interacción digital. Me interesa crear productos que no solo funcionen correctamente, sino que también comuniquen una identidad clara y memorable.',
|
|
||||||
tpAboutAria: 'Sobre Bryan Baño',
|
|
||||||
tpAboutLink: 'Acerca de mí',
|
|
||||||
tpAsciiError: 'No se pudo cargar el retrato.',
|
|
||||||
tpAsciiLoading: 'Cargando retrato...',
|
|
||||||
tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
|
tpEyebrow: 'INGENIERÍA EN COMPUTACIÓN & EXPERTISE',
|
||||||
tpTitle: 'Habilidades Técnicas & Áreas de Interés',
|
tpTitle: 'Habilidades Técnicas & Áreas de Interés',
|
||||||
tpLeadStudent: 'Estudiante de 9no semestre de Ingeniería en Computación en la',
|
tpLeadStudent: 'Estudiante de 9no semestre de Ingeniería en Computación en la',
|
||||||
|
|
@ -89,74 +71,33 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
|
||||||
tpInterest3Desc: 'Participación activa en eventos de difusión tecnológica (FLISOL, Google Developers Group).',
|
tpInterest3Desc: 'Participación activa en eventos de difusión tecnológica (FLISOL, Google Developers Group).',
|
||||||
|
|
||||||
// Featured Projects
|
// Featured Projects
|
||||||
fpHeaderTag: 'PROYECTOS',
|
fpHeaderTag: '01 // PROYECTOS',
|
||||||
fpAriaLabel: 'Proyectos reales de Bryan Baño',
|
fpAriaLabel: 'Proyectos reales de Bryan Baño',
|
||||||
fpPlaceholderImage: 'Sin capturas',
|
fpPlaceholderImage: 'Imagen',
|
||||||
fpAltPrefix: 'Captura de pantalla de ',
|
fpAltPrefix: 'Captura de pantalla de ',
|
||||||
fpCarouselPrev: 'Proyecto anterior',
|
prj1Subtitle: 'Acreditación Institucional CACES',
|
||||||
fpCarouselNext: 'Siguiente proyecto',
|
prj1Desc: 'Lideré el desarrollo de la plataforma centralizando la carga y validación de evidencias con seguridad avanzada mediante JWT y SharePoint.',
|
||||||
fpInDevelopment: 'En Desarrollo',
|
prj1Impact: 'Automatizó el seguimiento de indicadores, eliminando la gestión física de documentos con trazabilidad total para auditorías gubernamentales.',
|
||||||
fpNoImagesTechnical: 'Arquitectura & Auditoría CACES',
|
prj2Subtitle: 'Gestión Integral de Talleres & Órdenes',
|
||||||
projectDetailChallenge: 'Complicaciones',
|
prj2Institution: 'Sector Automotriz',
|
||||||
projectDetailSolution: 'Solución',
|
prj2Desc: 'Plataforma integral para control de inventarios de insumos mecánicos, registro detallado de vehículos y seguimiento de órdenes de trabajo en tiempo real.',
|
||||||
projectDetailResults: 'Resultados',
|
prj2Impact: 'Optimizó el flujo operativo del taller, eliminando el desabastecimiento de suministros críticos y mejorando los tiempos de entrega con historial digital.',
|
||||||
projectDetailCloseAria: 'Cerrar detalle del proyecto',
|
prj3Subtitle: 'Educación Virtual Acelerada',
|
||||||
|
prj3Institution: 'Plataforma Académica',
|
||||||
// PRJ-01: SIPLECE (Sin imágenes, enfoque técnico / acreditación)
|
prj3Desc: 'Lideré el desarrollo de la plataforma de gestión académica basada en arquitectura de APIs RESTful para alta disponibilidad en entornos remotos.',
|
||||||
prj1Subtitle: 'Acreditación CACES & Gestión Documental',
|
prj3Impact: 'Centraliza la administración académica y automatiza el seguimiento estudiantil, eliminando procesos manuales de inscripción y control de asistencia.',
|
||||||
prj1Desc: 'Plataforma institucional para centralizar, auditar y validar evidencias técnico-académicas requeridas por el CACES, integrando SharePoint y autenticación JWT con control de acceso por roles.',
|
|
||||||
prj1Impact: 'Automatizó el seguimiento de indicadores institucionales, erradicando la manipulación física de documentos y asegurando trazabilidad completa para auditorías gubernamentales.',
|
|
||||||
prj1Challenge: 'El proceso de acreditación estaba fragmentado entre múltiples hojas de cálculo, carpetas físicas desincronizadas y aprobaciones informales, lo que ocasionaba demoras críticas y riesgo de omisión de evidencias ante los evaluadores externos.',
|
|
||||||
prj1Solution: 'Diseñé una plataforma central bajo Clean Architecture con C# .NET y Angular, integrando SharePoint para almacenamiento cifrado, autenticación JWT con RBAC y flujos estructurados de aprobación multi-etapa con marcas de tiempo inmutables.',
|
|
||||||
prj1Result1: 'Reducción drástica del tiempo de revisión y validación de evidencias entre departamentos académicos y administrativos.',
|
|
||||||
prj1Result2: 'Trazabilidad y versionado al 100% de cada documento para auditorías oficiales del CACES.',
|
|
||||||
prj1Result3: 'Gobernanza institucional estandarizada para más de 100 indicadores y criterios de evaluación.',
|
|
||||||
|
|
||||||
// PRJ-02: Ammi-Online (Gestión académica completa con imágenes)
|
|
||||||
prj2Subtitle: 'Gestión Académica, Solicitudes, Pagos & Calificaciones',
|
|
||||||
prj2Institution: 'Educación Superior / Academia',
|
|
||||||
prj2Desc: 'Ecosistema web integral para la administración del ciclo estudiantil completo: solicitudes de admisión, matriculación automatizada, procesamiento de pagos y registro de calificaciones en tiempo real.',
|
|
||||||
prj2Impact: 'Unificó las operaciones académicas y financieras en una sola plataforma de alta concurrencia, reduciendo el tiempo de matriculación y garantizando conciliación de pagos inmediata.',
|
|
||||||
prj2Challenge: 'Procesos de admisión dispersos, conciliación manual de pagos y comprobantes bancarios, y dispersión de actas de calificaciones en hojas de cálculo vulnerables a inconsistencias.',
|
|
||||||
prj2Solution: 'Implementé una arquitectura limpia desacoplada con backend en C# .NET 8 (CQRS con MediatR) y frontend Angular con Signals. Módulos integrados para solicitudes, pasarelas de pago, validación contable y actas de calificaciones con RBAC estricto desde base de datos y JWT.',
|
|
||||||
prj2Result1: 'Automatización del 100% del flujo de matrícula y validación digital de pagos sin intervención manual.',
|
|
||||||
prj2Result2: 'Centralización de actas de notas con auditoría rigurosa de modificaciones por docente.',
|
|
||||||
prj2Result3: 'Navegación reactiva instantánea para miles de estudiantes simultáneos mediante ChangeDetection OnPush y Signals.',
|
|
||||||
|
|
||||||
// PRJ-03: Módulo Vacaciones & Permisos RRHH (Con imágenes)
|
|
||||||
prj3Subtitle: 'Control de Vacaciones, Algoritmo FIFO & Actas en Tiempo Real',
|
|
||||||
prj3Institution: 'Corporativo / Talento Humano',
|
|
||||||
prj3Desc: 'Módulo integral de recursos humanos apegado a la legislación laboral para la administración de vacaciones de empleados, cálculo por algoritmo FIFO de periodos acumulados, actas en tiempo real y dashboards analíticos.',
|
|
||||||
prj3Impact: 'Automatizó integralmente el ciclo de vacaciones y permisos, erradicando el cálculo manual de pasivos laborales y garantizando 100% de apego a la normativa legal vigente.',
|
|
||||||
prj3Challenge: 'Cálculo manual propenso a discrepancias legales en periodos acumulados por antigüedad, deducciones por permisos imprevistos, cierres institucionales y falta de generación inmediata de actas con validez jurídica.',
|
|
||||||
prj3Solution: 'Desarrollé un motor de liquidación vacacional bajo algoritmo FIFO con Clean Architecture, generación de actas en tiempo real en PDF, dashboards dinámicos y configuración al 100% de días extras por departamento, incentivos y cierres institucionales. Todo con gestión de permisos vinculada a descuentos dinámicos, RBAC en base de datos y JWT.',
|
|
||||||
prj3Result1: 'Generación instantánea de actas de vacaciones con firma y trazabilidad legal en tiempo real.',
|
|
||||||
prj3Result2: 'Descuento dinámico y transparente de permisos directamente vinculado al saldo y periodo correspondiente.',
|
|
||||||
prj3Result3: 'Dashboards analíticos de absentismo y pasivos laborales para la toma de decisiones directivas.',
|
|
||||||
|
|
||||||
// PRJ-04: Sistema de Titulación Académica (En desarrollo, con imágenes)
|
|
||||||
prj4Subtitle: 'Convocatorias, Modalidades & Matriz de Grado',
|
|
||||||
prj4Institution: 'Educación Superior',
|
|
||||||
prj4Desc: 'Plataforma para la administración integral de procesos de titulación: postulación de aspirantes, seguimiento por modalidades (artículo científico, examen complexivo, proyectos TIC) y validación de requisitos de grado por convocatorias.',
|
|
||||||
prj4Impact: 'Estandariza el ciclo de graduación universitaria mediante convocatorias parametrizables, garantizando transparencia absoluta entre estudiantes, tutores y evaluadores.',
|
|
||||||
prj4Challenge: 'Dificultad para coordinar postulaciones con requisitos dispares por modalidad de titulación, validaciones manuales de horas de vinculación/prácticas y ausencia de un canal unificado para tribunales examinadores.',
|
|
||||||
prj4Solution: 'Diseñé una plataforma basada en Clean Architecture (.NET + Angular Signals) con motor de convocatorias dinámicas abiertas por el administrador. Incluye RBAC desde base de datos con 4 roles (Administrador, Estudiante, Profesor y Evaluador), matrices de seguimiento y validaciones personalizadas.',
|
|
||||||
prj4Result1: 'Gestión estructurada por convocatorias con apertura, postulación, dictamen de jurado y defensa final.',
|
|
||||||
prj4Result2: 'Soporte configurable para modalidades de artículo científico, examen complexivo y proyectos TIC.',
|
|
||||||
prj4Result3: 'Seguridad robusta con JWT, permisos por rol desde base de datos y control exhaustivo de requisitos previos.',
|
|
||||||
|
|
||||||
// Terminal & Workstation
|
// Terminal & Workstation
|
||||||
terminalWorkstationTag: 'INTERACTIVE WORKSTATION',
|
terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION',
|
||||||
terminalHeading: 'Terminal Arch Linux // Fish Shell',
|
terminalHeading: 'Terminal Arch Linux // Fish Shell',
|
||||||
terminalSectionDesc: 'Consola interactiva a escala completa para consultar el ecosistema técnico, habilidades y base de datos.',
|
terminalSectionDesc: 'Consola interactiva a escala completa para consultar el ecosistema técnico, habilidades y base de datos.',
|
||||||
terminalBanner: `bryan@archlinux
|
terminalBanner: ` /\\ bryan@archlinux
|
||||||
OS: Arch Linux x86_64
|
/ \\ OS: Arch Linux x86_64
|
||||||
Host: ThinkPad Z13 Gen 2
|
/\\ \\ Host: ThinkPad Z13 Gen 2
|
||||||
Kernel: 6.10.7-arch1-1
|
/ \\ Kernel: 6.10.7-arch1-1
|
||||||
Shell: fish 3.7.0
|
/ ,, \\ Shell: fish 3.7.0
|
||||||
Terminal: zen-alacritty
|
/ | | -\\ Terminal: zen-alacritty
|
||||||
|
/_-'' ''-_\\ Escribe "help" para comenzar.`,
|
||||||
Escribe "help" para comenzar.`,
|
|
||||||
terminalHint: 'Bienvenido al entorno interactivo Arch Linux / Fish Shell.\nEscribe "help" para ver los comandos disponibles.',
|
terminalHint: 'Bienvenido al entorno interactivo Arch Linux / Fish Shell.\nEscribe "help" para ver los comandos disponibles.',
|
||||||
terminalHelpContent: `Arch Linux (x86_64) — fish, version 3.7.0
|
terminalHelpContent: `Arch Linux (x86_64) — fish, version 3.7.0
|
||||||
Comandos disponibles para la sesión interactiva:
|
Comandos disponibles para la sesión interactiva:
|
||||||
|
|
@ -183,7 +124,7 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
|
||||||
terminalUnknownCmd: "fish: Comando desconocido '{cmd}'. Escribe 'help' para ver la lista de comandos disponibles.",
|
terminalUnknownCmd: "fish: Comando desconocido '{cmd}'. Escribe 'help' para ver la lista de comandos disponibles.",
|
||||||
|
|
||||||
// Contact Section
|
// Contact Section
|
||||||
contactEyebrow: 'CONTACTO',
|
contactEyebrow: '03 // CONTACTO',
|
||||||
contactTitle: 'Software a Medida, Arquitectura Limpia & Seguridad por Diseño',
|
contactTitle: 'Software a Medida, Arquitectura Limpia & Seguridad por Diseño',
|
||||||
contactLead: 'Desarrollo Full Stack (Angular, C# .NET), arquitecturas basadas en microservicios, optimización cloud en AWS y diseño de bases de datos relacionales. Cuéntame tu proyecto y te responderé con una propuesta técnica en menos de 24 horas.',
|
contactLead: 'Desarrollo Full Stack (Angular, C# .NET), arquitecturas basadas en microservicios, optimización cloud en AWS y diseño de bases de datos relacionales. Cuéntame tu proyecto y te responderé con una propuesta técnica en menos de 24 horas.',
|
||||||
contactService1Title: 'Desarrollo Full Stack & Arquitectura Limpia',
|
contactService1Title: 'Desarrollo Full Stack & Arquitectura Limpia',
|
||||||
|
|
@ -264,71 +205,28 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
|
||||||
retroSetScanlinesDesc: 'Líneas de barrido de monitor CRT analógico',
|
retroSetScanlinesDesc: 'Líneas de barrido de monitor CRT analógico',
|
||||||
retroSetSound: 'Sonido Web Audio API',
|
retroSetSound: 'Sonido Web Audio API',
|
||||||
retroSetSoundDesc: 'Efectos mecánicos y arcade sintéticos',
|
retroSetSoundDesc: 'Efectos mecánicos y arcade sintéticos',
|
||||||
retroScoresHeader: '★ TABLA DE RÉCORDS',
|
retroScoresHeader: '★ ARCADE LEADERBOARD',
|
||||||
retroScoresTitle: 'BUSCAMINAS // TOP 10',
|
retroScoresTitle: 'ZEN SNAKE // TOP 10',
|
||||||
retroScoresRefresh: 'ACTUALIZAR',
|
retroScoresRefresh: 'ACTUALIZAR',
|
||||||
retroScoresRank: '#',
|
retroScoresRank: '#',
|
||||||
retroScoresTag: 'TAG',
|
retroScoresTag: 'TAG',
|
||||||
retroScoresScore: 'TIEMPO (S)',
|
retroScoresScore: 'PUNTOS',
|
||||||
retroScoresDate: 'FECHA',
|
retroScoresDate: 'FECHA',
|
||||||
retroScoresEmpty: 'No hay puntuaciones registradas',
|
retroScoresEmpty: 'No hay puntuaciones registradas',
|
||||||
retroGameScore: 'TIEMPO:',
|
retroGameScore: 'PUNTOS:',
|
||||||
retroGameHigh: 'MEJOR:',
|
retroGameHigh: 'RÉCORD:',
|
||||||
retroGameStart: '▶ Iniciar',
|
retroGameStart: '▶ Iniciar',
|
||||||
retroGameRestart: 'Reiniciar',
|
retroGameRestart: 'Reiniciar',
|
||||||
retroGamePause: 'Pausa',
|
retroGamePause: 'Pausa',
|
||||||
retroGameResume: 'Reanudar',
|
retroGameResume: 'Reanudar',
|
||||||
retroGameBanner: 'Haz clic en una casilla para comenzar',
|
retroGameBanner: 'Pulsa cualquier Flecha o haz clic para empezar',
|
||||||
retroGameOver: '¡BOOM! HAS PERDIDO',
|
retroGameOver: '¡FIN DE LA PARTIDA!',
|
||||||
retroGameScoreLabel: 'Tiempo:',
|
retroGameScoreLabel: 'Puntuación:',
|
||||||
retroGameSave: 'Guardar',
|
retroGameSave: 'Guardar',
|
||||||
retroGameSaved: '✓ Récord guardado',
|
retroGameSaved: '✓ Récord guardado',
|
||||||
retroGameWin: '¡VICTORIA! MINAS DESPEJADAS',
|
retroGamePlayAgain: '↺ Jugar Otra Vez',
|
||||||
retroGameMines: 'Minas:',
|
retroGameUp: 'Arriba',
|
||||||
retroGameFlagMode: 'Marcar Bandera',
|
retroGameLeft: 'Izquierda',
|
||||||
retroGameDigMode: 'Descubrir',
|
retroGameDown: 'Abajo',
|
||||||
|
retroGameRight: 'Derecha'
|
||||||
// Simulador de Respuesta a Incidentes Cloud
|
|
||||||
simTag: 'ARQUITECTURA CLOUD & SEGURIDAD SERVERLESS',
|
|
||||||
simHeading: 'Simulador de Respuesta a Incidentes en Tiempo Real',
|
|
||||||
simSubheading: 'Visualización interactiva de una arquitectura de contención reactiva en AWS. Experimenta cómo un evento de acceso IAM no autorizado es detectado, mitigado por una función Lambda y reportado vía SES en menos de 180 ms sin intervención manual.',
|
|
||||||
simModeStory: 'Vista Explicada (Negocio)',
|
|
||||||
simModeTech: 'Vista Telemetría SecOps (Dev)',
|
|
||||||
simEmailLabel: 'Correo para recibir el reporte forense:',
|
|
||||||
simEmailPlaceholder: 'tu-correo@empresa.com',
|
|
||||||
simEmailHint: 'Ingresa tu correo para recibir la auditoría criptográfica del incidente mitigado.',
|
|
||||||
simBtnSimulate: 'Simular Ataque y Defensa',
|
|
||||||
simBtnSimulating: 'Conteniendo Amenaza en Tiempo Real...',
|
|
||||||
simBtnReset: 'Reiniciar Simulación',
|
|
||||||
simStep1Title: 'Intento de Intrusión',
|
|
||||||
simStep1Desc: 'Un atacante intenta robar credenciales de administración desde una IP no autorizada.',
|
|
||||||
simStep2Title: 'Detección Instantánea',
|
|
||||||
simStep2Desc: 'El sistema monitorea anomalías y dispara la regla de defensa en menos de 50 milisegundos.',
|
|
||||||
simStep3Title: 'Bloqueo Automático',
|
|
||||||
simStep3Desc: 'Una función serverless revoca las sesiones y sella el perímetro sin intervención humana.',
|
|
||||||
simStep4Title: 'Notificación y Reporte',
|
|
||||||
simStep4Desc: 'Se compila la evidencia forense y se despacha el certificado de seguridad por correo.',
|
|
||||||
simThreatNodeTitle: 'Vector de Amenaza',
|
|
||||||
simThreatNodeSub: 'iam:CreateAccessKey anómalo',
|
|
||||||
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
|
|
||||||
simCloudTrailNodeSub: 'Ingesta y análisis de eventos',
|
|
||||||
simLambdaNodeTitle: 'AWS Lambda (Remediación)',
|
|
||||||
simLambdaNodeSub: 'Revocación de sesión & DenyAll',
|
|
||||||
simSesNodeTitle: 'Amazon SES / SecOps Alert',
|
|
||||||
simSesNodeSub: 'Reporte de incidente despachado',
|
|
||||||
simConsoleTitle: 'Consola de Telemetría SecOps // Live Stream',
|
|
||||||
simMetricLatency: 'Tiempo de Contención',
|
|
||||||
simMetricIntervention: 'Intervención Manual',
|
|
||||||
simMetricArchitecture: 'Arquitectura Serverless',
|
|
||||||
simStatusIdle: 'SISTEMA NOMINAL // ESCUCHANDO EVENTOS',
|
|
||||||
simStatusActive: 'AMENAZA DETECTADA // EJECUTANDO MITIGACIÓN',
|
|
||||||
simStatusContained: 'AMENAZA NEUTRALIZADA // ESTADO SEGURO RESTAURADO',
|
|
||||||
simReportTitle: 'CERTIFICADO FORENSE DE INCIDENTE MITIGADO',
|
|
||||||
simReportTargetEmail: 'Destinatario de Auditoría:',
|
|
||||||
simReportStatusSecure: 'ESTADO: 100% SEGURO // SIN FILTRACIONES',
|
|
||||||
simReportTimeVsAvg: '184 ms (Vs. 4.2 horas promedio de la industria)',
|
|
||||||
simReportCredentialsCompromised: '0 credenciales comprometidas',
|
|
||||||
simReportFinancialLoss: '$0.00 USD (Daño financiero evitado)',
|
|
||||||
simReportDownloadBtn: 'Descargar Reporte Forense (Demo PDF)',
|
|
||||||
simReportDemoBadge: 'CERTIFICADO AUTOGENERADO EN VIVO'
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,83 +42,23 @@ export class RetroAudioService {
|
||||||
osc.stop(ctx.currentTime + 0.02);
|
osc.stop(ctx.currentTime + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
playCellReveal(): void {
|
playSnakeEat(): void {
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator();
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
osc.type = 'triangle';
|
osc.type = 'sine';
|
||||||
osc.frequency.setValueAtTime(520, ctx.currentTime);
|
osc.frequency.setValueAtTime(440, ctx.currentTime);
|
||||||
osc.frequency.exponentialRampToValueAtTime(780, ctx.currentTime + 0.03);
|
osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.08);
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.04, ctx.currentTime);
|
gain.gain.setValueAtTime(0.06, ctx.currentTime);
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
|
||||||
|
|
||||||
osc.connect(gain);
|
osc.connect(gain);
|
||||||
gain.connect(ctx.destination);
|
gain.connect(ctx.destination);
|
||||||
osc.start();
|
osc.start();
|
||||||
osc.stop(ctx.currentTime + 0.03);
|
osc.stop(ctx.currentTime + 0.08);
|
||||||
}
|
|
||||||
|
|
||||||
playFlagToggle(): void {
|
|
||||||
const ctx = this.getContext();
|
|
||||||
if (!ctx) return;
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
|
|
||||||
osc.type = 'square';
|
|
||||||
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
||||||
osc.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.025);
|
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.03, ctx.currentTime);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025);
|
|
||||||
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
osc.start();
|
|
||||||
osc.stop(ctx.currentTime + 0.025);
|
|
||||||
}
|
|
||||||
|
|
||||||
playMineExplosion(): void {
|
|
||||||
const ctx = this.getContext();
|
|
||||||
if (!ctx) return;
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
|
|
||||||
osc.type = 'sawtooth';
|
|
||||||
osc.frequency.setValueAtTime(180, ctx.currentTime);
|
|
||||||
osc.frequency.exponentialRampToValueAtTime(30, ctx.currentTime + 0.45);
|
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.12, ctx.currentTime);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
|
|
||||||
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
osc.start();
|
|
||||||
osc.stop(ctx.currentTime + 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
playVictory(): void {
|
|
||||||
const ctx = this.getContext();
|
|
||||||
if (!ctx) return;
|
|
||||||
const notes = [523.25, 659.25, 783.99, 1046.50]; // Do, Mi, Sol, Do alto
|
|
||||||
notes.forEach((freq, index) => {
|
|
||||||
const startTime = ctx.currentTime + index * 0.08;
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
|
|
||||||
osc.type = 'sine';
|
|
||||||
osc.frequency.setValueAtTime(freq, startTime);
|
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.06, startTime);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.15);
|
|
||||||
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
osc.start(startTime);
|
|
||||||
osc.stop(startTime + 0.15);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playGameOver(): void {
|
playGameOver(): void {
|
||||||
|
|
@ -161,36 +101,4 @@ export class RetroAudioService {
|
||||||
osc.stop(startTime + 0.06);
|
osc.stop(startTime + 0.06);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
playStartup(): void {
|
|
||||||
const ctx = this.getContext();
|
|
||||||
if (!ctx) return;
|
|
||||||
// Acorde de inicio ascendente emblemático (Eb, Bb, Eb, G, Ab, Bb)
|
|
||||||
const startupChords = [
|
|
||||||
{ f: 155.56, t: 0.0, d: 1.2, v: 0.04 }, // Eb3
|
|
||||||
{ f: 233.08, t: 0.15, d: 1.2, v: 0.04 }, // Bb3
|
|
||||||
{ f: 311.13, t: 0.35, d: 1.4, v: 0.05 }, // Eb4
|
|
||||||
{ f: 392.00, t: 0.55, d: 1.4, v: 0.05 }, // G4
|
|
||||||
{ f: 466.16, t: 0.75, d: 1.8, v: 0.06 }, // Bb4
|
|
||||||
{ f: 622.25, t: 0.95, d: 2.2, v: 0.05 } // Eb5
|
|
||||||
];
|
|
||||||
|
|
||||||
startupChords.forEach(note => {
|
|
||||||
const startTime = ctx.currentTime + note.t;
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
|
|
||||||
osc.type = 'sine';
|
|
||||||
osc.frequency.setValueAtTime(note.f, startTime);
|
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.001, startTime);
|
|
||||||
gain.gain.linearRampToValueAtTime(note.v, startTime + 0.08);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + note.d);
|
|
||||||
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
osc.start(startTime);
|
|
||||||
osc.stop(startTime + note.d);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
|
||||||
import { ChatPort } from '../../domain/ports/chat.port';
|
|
||||||
import { ChatMessage } from '../../domain/models/chat.model';
|
|
||||||
import { RetroAudioService } from '../services/retro-audio.service';
|
|
||||||
import { I18nService } from '../services/i18n.service';
|
|
||||||
|
|
||||||
const SESSION_STORAGE_KEY = 'bryan_portfolio_chat_session';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class ChatStore {
|
|
||||||
private readonly chatPort = inject(ChatPort);
|
|
||||||
private readonly audio = inject(RetroAudioService);
|
|
||||||
private readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
private readonly _isOpen = signal<boolean>(false);
|
|
||||||
private readonly _isTyping = signal<boolean>(false);
|
|
||||||
private readonly _unreadCount = signal<number>(0);
|
|
||||||
private readonly _errorMessage = signal<string | null>(null);
|
|
||||||
private readonly _history = signal<ChatMessage[]>([]);
|
|
||||||
private readonly _sessionId = signal<string>(this.initSessionId());
|
|
||||||
|
|
||||||
readonly isOpen = this._isOpen.asReadonly();
|
|
||||||
readonly isTyping = this._isTyping.asReadonly();
|
|
||||||
readonly unreadCount = this._unreadCount.asReadonly();
|
|
||||||
readonly errorMessage = this._errorMessage.asReadonly();
|
|
||||||
readonly hasUnread = computed(() => this._unreadCount() > 0);
|
|
||||||
|
|
||||||
// Mensajes computados de forma reactiva y pura, sin efectos que causen NG0600
|
|
||||||
readonly messages = computed<ChatMessage[]>(() => {
|
|
||||||
const isEs = this.i18n.currentLang() === 'es';
|
|
||||||
const greeting: ChatMessage = {
|
|
||||||
id: 'greeting',
|
|
||||||
role: 'assistant',
|
|
||||||
content: isEs
|
|
||||||
? 'Hola, soy el asistente virtual de Bryan Baño. Puedo responder dudas sobre su perfil, Clean Architecture en .NET / Angular o sus proyectos. ¿En qué te puedo orientar?'
|
|
||||||
: "Hello, I am Bryan Baño's virtual assistant. I can answer questions about his skills, Clean Architecture in .NET / Angular, or his projects. How can I assist you?",
|
|
||||||
timestamp: 'Zen AI',
|
|
||||||
status: 'sent'
|
|
||||||
};
|
|
||||||
return [greeting, ...this._history()];
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly quickPrompts = computed(() => {
|
|
||||||
const isEs = this.i18n.currentLang() === 'es';
|
|
||||||
return isEs
|
|
||||||
? [
|
|
||||||
'¿Cuál es tu stack principal?',
|
|
||||||
'¿Qué proyectos has realizado?',
|
|
||||||
'¿Cómo puedo contactarte?'
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
'What is your main tech stack?',
|
|
||||||
'What projects have you built?',
|
|
||||||
'How can I get in touch?'
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
private initSessionId(): string {
|
|
||||||
if (typeof window === 'undefined') return `session-${Date.now()}`;
|
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
||||||
if (saved) return saved;
|
|
||||||
const newId =
|
|
||||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `session-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
||||||
sessionStorage.setItem(SESSION_STORAGE_KEY, newId);
|
|
||||||
return newId;
|
|
||||||
} catch {
|
|
||||||
return `session-${Date.now()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleChat(): void {
|
|
||||||
const nextState = !this._isOpen();
|
|
||||||
this._isOpen.set(nextState);
|
|
||||||
if (nextState) {
|
|
||||||
this._unreadCount.set(0);
|
|
||||||
this.audio.playClick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openChat(): void {
|
|
||||||
this._isOpen.set(true);
|
|
||||||
this._unreadCount.set(0);
|
|
||||||
this.audio.playClick();
|
|
||||||
}
|
|
||||||
|
|
||||||
closeChat(): void {
|
|
||||||
this._isOpen.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHistory(): void {
|
|
||||||
this._errorMessage.set(null);
|
|
||||||
this._history.set([]);
|
|
||||||
this.audio.playClick();
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage(rawText: string): void {
|
|
||||||
const text = rawText.trim();
|
|
||||||
if (!text || this._isTyping()) return;
|
|
||||||
|
|
||||||
this._errorMessage.set(null);
|
|
||||||
|
|
||||||
const timeStr = typeof Intl !== 'undefined'
|
|
||||||
? new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
id: `usr-${Date.now()}`,
|
|
||||||
role: 'user',
|
|
||||||
content: text,
|
|
||||||
timestamp: timeStr,
|
|
||||||
status: 'sent'
|
|
||||||
};
|
|
||||||
|
|
||||||
this._history.update(prev => [...prev, userMessage]);
|
|
||||||
this._isTyping.set(true);
|
|
||||||
this.audio.playClick();
|
|
||||||
|
|
||||||
this.chatPort.sendMessage({ message: text, sessionId: this._sessionId() }).subscribe({
|
|
||||||
next: res => {
|
|
||||||
const assistantMessage: ChatMessage = {
|
|
||||||
id: `bot-${Date.now()}`,
|
|
||||||
role: 'assistant',
|
|
||||||
content: res.reply,
|
|
||||||
timestamp: timeStr,
|
|
||||||
status: 'sent'
|
|
||||||
};
|
|
||||||
|
|
||||||
this._history.update(prev => [...prev, assistantMessage]);
|
|
||||||
this._isTyping.set(false);
|
|
||||||
|
|
||||||
if (!this._isOpen()) {
|
|
||||||
this._unreadCount.update(c => c + 1);
|
|
||||||
}
|
|
||||||
this.audio.playHighScore();
|
|
||||||
},
|
|
||||||
error: err => {
|
|
||||||
this._isTyping.set(false);
|
|
||||||
this._errorMessage.set(err.message ?? 'No se pudo recibir respuesta del asistente.');
|
|
||||||
|
|
||||||
const errorAssistantMsg: ChatMessage = {
|
|
||||||
id: `err-${Date.now()}`,
|
|
||||||
role: 'assistant',
|
|
||||||
content: err.message ?? 'Lo siento, ocurrió un error de conexión con el agente.',
|
|
||||||
timestamp: timeStr,
|
|
||||||
status: 'error'
|
|
||||||
};
|
|
||||||
this._history.update(prev => [...prev, errorAssistantMsg]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { Injectable, signal, computed, inject, effect } from '@angular/core';
|
import { Injectable, signal, computed, inject, effect } from '@angular/core';
|
||||||
import { WindowId, RetroWindow, DesktopIcon, ZenOsTheme, ZenOsConfig, WindowPosition, WindowSize, OsPowerState } from '../../domain/models/retro-os.model';
|
import { WindowId, RetroWindow, DesktopIcon, ZenOsTheme, ZenOsConfig, WindowPosition, WindowSize } from '../../domain/models/retro-os.model';
|
||||||
import { RetroAudioService } from '../services/retro-audio.service';
|
import { RetroAudioService } from '../services/retro-audio.service';
|
||||||
import { I18nService } from '../services/i18n.service';
|
import { I18nService } from '../services/i18n.service';
|
||||||
|
|
||||||
const INITIAL_WINDOWS: Record<WindowId, RetroWindow> = {
|
const INITIAL_WINDOWS: Record<WindowId, RetroWindow> = {
|
||||||
games: {
|
games: {
|
||||||
id: 'games',
|
id: 'games',
|
||||||
title: 'Buscaminas - Juegos Clásicos',
|
title: 'Snake - Juegos de Windows XP',
|
||||||
icon: 'minesweeper',
|
icon: 'gamepad',
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isMinimized: false,
|
isMinimized: false,
|
||||||
position: { x: 70, y: 20 },
|
position: { x: 70, y: 20 },
|
||||||
size: { width: 270, height: 360 },
|
size: { width: 360, height: 490 },
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
minSize: { width: 260, height: 340 }
|
minSize: { width: 320, height: 380 }
|
||||||
},
|
},
|
||||||
cv: {
|
cv: {
|
||||||
id: 'cv',
|
id: 'cv',
|
||||||
|
|
@ -56,7 +56,6 @@ export class RetroOsStore {
|
||||||
private readonly audio = inject(RetroAudioService);
|
private readonly audio = inject(RetroAudioService);
|
||||||
private readonly i18n = inject(I18nService);
|
private readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
private readonly _powerState = signal<OsPowerState>('off');
|
|
||||||
private readonly _windows = signal<Record<WindowId, RetroWindow>>(INITIAL_WINDOWS);
|
private readonly _windows = signal<Record<WindowId, RetroWindow>>(INITIAL_WINDOWS);
|
||||||
private readonly _activeWindowId = signal<WindowId | null>(null);
|
private readonly _activeWindowId = signal<WindowId | null>(null);
|
||||||
private readonly _topZIndex = signal<number>(20);
|
private readonly _topZIndex = signal<number>(20);
|
||||||
|
|
@ -65,12 +64,10 @@ export class RetroOsStore {
|
||||||
private readonly _scanlines = signal<boolean>(this.getSavedBoolean('zenos_scanlines', true));
|
private readonly _scanlines = signal<boolean>(this.getSavedBoolean('zenos_scanlines', true));
|
||||||
private readonly _soundEnabled = signal<boolean>(this.getSavedBoolean('zenos_sound', true));
|
private readonly _soundEnabled = signal<boolean>(this.getSavedBoolean('zenos_sound', true));
|
||||||
|
|
||||||
readonly powerState = this._powerState.asReadonly();
|
|
||||||
|
|
||||||
readonly desktopIcons = computed<DesktopIcon[]>(() => {
|
readonly desktopIcons = computed<DesktopIcon[]>(() => {
|
||||||
const t = this.i18n.t();
|
const t = this.i18n.t();
|
||||||
return [
|
return [
|
||||||
{ id: 'icon-games', label: t.retroIconGames, windowId: 'games', iconType: 'minesweeper' },
|
{ id: 'icon-games', label: t.retroIconGames, windowId: 'games', iconType: 'gamepad' },
|
||||||
{ id: 'icon-cv', label: t.retroIconCv, windowId: 'cv', iconType: 'document' },
|
{ id: 'icon-cv', label: t.retroIconCv, windowId: 'cv', iconType: 'document' },
|
||||||
{ id: 'icon-scores', label: t.retroIconScores, windowId: 'scores', iconType: 'trophy' },
|
{ id: 'icon-scores', label: t.retroIconScores, windowId: 'scores', iconType: 'trophy' },
|
||||||
{ id: 'icon-settings', label: t.retroIconSettings, windowId: 'settings', iconType: 'gear' }
|
{ id: 'icon-settings', label: t.retroIconSettings, windowId: 'settings', iconType: 'gear' }
|
||||||
|
|
@ -222,29 +219,6 @@ export class RetroOsStore {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startBootSequence(): void {
|
|
||||||
if (this._powerState() !== 'off') return;
|
|
||||||
this._powerState.set('booting');
|
|
||||||
|
|
||||||
// Transición directa de barra de carga XP a escritorio
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this._powerState() === 'booting') {
|
|
||||||
this.audio.playStartup();
|
|
||||||
this._powerState.set('desktop');
|
|
||||||
}
|
|
||||||
}, 2400);
|
|
||||||
}
|
|
||||||
|
|
||||||
forceDesktop(): void {
|
|
||||||
this._powerState.set('desktop');
|
|
||||||
}
|
|
||||||
|
|
||||||
resetToOff(): void {
|
|
||||||
this._powerState.set('off');
|
|
||||||
this._windows.set(INITIAL_WINDOWS);
|
|
||||||
this._activeWindowId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyThemeToDocument(theme: ZenOsTheme): void {
|
private applyThemeToDocument(theme: ZenOsTheme): void {
|
||||||
if (typeof document !== 'undefined' && document.documentElement) {
|
if (typeof document !== 'undefined' && document.documentElement) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { InjectionToken } from '@angular/core';
|
|
||||||
|
|
||||||
export const CHAT_WEBHOOK_URL = new InjectionToken<string>('CHAT_WEBHOOK_URL', {
|
|
||||||
providedIn: 'root',
|
|
||||||
// Por defecto apunta a un endpoint relativo o variable local; se puede sobreescribir en app.config.ts
|
|
||||||
factory: () => 'https://chat-api.bryanbano.com/webhook/portfolio-chat'
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { InjectionToken } from '@angular/core';
|
|
||||||
|
|
||||||
export const SECURITY_AGENT_WEBHOOK_URL = new InjectionToken<string>('SECURITY_AGENT_WEBHOOK_URL', {
|
|
||||||
providedIn: 'root',
|
|
||||||
factory: () => 'https://n8n.bryan-bano.com/webhook/security-agent'
|
|
||||||
});
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
export type ChatMessageRole = 'user' | 'assistant' | 'system';
|
|
||||||
export type ChatMessageStatus = 'sending' | 'sent' | 'error';
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
readonly id: string;
|
|
||||||
readonly role: ChatMessageRole;
|
|
||||||
readonly content: string;
|
|
||||||
readonly timestamp: string;
|
|
||||||
readonly status: ChatMessageStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatPayload {
|
|
||||||
readonly message: string;
|
|
||||||
readonly sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatResponse {
|
|
||||||
readonly reply: string;
|
|
||||||
readonly sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
@ -6,17 +6,6 @@ export interface TranslationDictionary {
|
||||||
readonly switchLangAria: string;
|
readonly switchLangAria: string;
|
||||||
readonly footerCopy: string;
|
readonly footerCopy: string;
|
||||||
|
|
||||||
// Navbar Quick Navigation
|
|
||||||
readonly navHome: string;
|
|
||||||
readonly navRetro: string;
|
|
||||||
readonly navProjects: string;
|
|
||||||
readonly navSimulator: string;
|
|
||||||
readonly navTerminal: string;
|
|
||||||
readonly navContact: string;
|
|
||||||
readonly navAll: string;
|
|
||||||
readonly navPrevSection: string;
|
|
||||||
readonly navNextSection: string;
|
|
||||||
|
|
||||||
// Hero 3D
|
// Hero 3D
|
||||||
readonly preloading: string;
|
readonly preloading: string;
|
||||||
readonly heroAriaExperience: string;
|
readonly heroAriaExperience: string;
|
||||||
|
|
@ -61,13 +50,6 @@ export interface TranslationDictionary {
|
||||||
readonly p3RightBadge2: string;
|
readonly p3RightBadge2: string;
|
||||||
|
|
||||||
// Technical Profile
|
// Technical Profile
|
||||||
readonly tpAboutGreeting: string;
|
|
||||||
readonly tpAboutName: string;
|
|
||||||
readonly tpAboutDescription: string;
|
|
||||||
readonly tpAboutAria: string;
|
|
||||||
readonly tpAboutLink: string;
|
|
||||||
readonly tpAsciiError: string;
|
|
||||||
readonly tpAsciiLoading: string;
|
|
||||||
readonly tpEyebrow: string;
|
readonly tpEyebrow: string;
|
||||||
readonly tpTitle: string;
|
readonly tpTitle: string;
|
||||||
readonly tpLeadStudent: string;
|
readonly tpLeadStudent: string;
|
||||||
|
|
@ -93,49 +75,17 @@ export interface TranslationDictionary {
|
||||||
readonly fpAriaLabel: string;
|
readonly fpAriaLabel: string;
|
||||||
readonly fpPlaceholderImage: string;
|
readonly fpPlaceholderImage: string;
|
||||||
readonly fpAltPrefix: string;
|
readonly fpAltPrefix: string;
|
||||||
readonly fpCarouselPrev: string;
|
|
||||||
readonly fpCarouselNext: string;
|
|
||||||
readonly fpInDevelopment: string;
|
|
||||||
readonly fpNoImagesTechnical: string;
|
|
||||||
readonly projectDetailChallenge: string;
|
|
||||||
readonly projectDetailSolution: string;
|
|
||||||
readonly projectDetailResults: string;
|
|
||||||
readonly projectDetailCloseAria: string;
|
|
||||||
readonly prj1Subtitle: string;
|
readonly prj1Subtitle: string;
|
||||||
readonly prj1Desc: string;
|
readonly prj1Desc: string;
|
||||||
readonly prj1Impact: string;
|
readonly prj1Impact: string;
|
||||||
readonly prj1Challenge: string;
|
|
||||||
readonly prj1Solution: string;
|
|
||||||
readonly prj1Result1: string;
|
|
||||||
readonly prj1Result2: string;
|
|
||||||
readonly prj1Result3: string;
|
|
||||||
readonly prj2Subtitle: string;
|
readonly prj2Subtitle: string;
|
||||||
readonly prj2Institution: string;
|
readonly prj2Institution: string;
|
||||||
readonly prj2Desc: string;
|
readonly prj2Desc: string;
|
||||||
readonly prj2Impact: string;
|
readonly prj2Impact: string;
|
||||||
readonly prj2Challenge: string;
|
|
||||||
readonly prj2Solution: string;
|
|
||||||
readonly prj2Result1: string;
|
|
||||||
readonly prj2Result2: string;
|
|
||||||
readonly prj2Result3: string;
|
|
||||||
readonly prj3Subtitle: string;
|
readonly prj3Subtitle: string;
|
||||||
readonly prj3Institution: string;
|
readonly prj3Institution: string;
|
||||||
readonly prj3Desc: string;
|
readonly prj3Desc: string;
|
||||||
readonly prj3Impact: string;
|
readonly prj3Impact: string;
|
||||||
readonly prj3Challenge: string;
|
|
||||||
readonly prj3Solution: string;
|
|
||||||
readonly prj3Result1: string;
|
|
||||||
readonly prj3Result2: string;
|
|
||||||
readonly prj3Result3: string;
|
|
||||||
readonly prj4Subtitle: string;
|
|
||||||
readonly prj4Institution: string;
|
|
||||||
readonly prj4Desc: string;
|
|
||||||
readonly prj4Impact: string;
|
|
||||||
readonly prj4Challenge: string;
|
|
||||||
readonly prj4Solution: string;
|
|
||||||
readonly prj4Result1: string;
|
|
||||||
readonly prj4Result2: string;
|
|
||||||
readonly prj4Result3: string;
|
|
||||||
|
|
||||||
// Terminal & Workstation
|
// Terminal & Workstation
|
||||||
readonly terminalWorkstationTag: string;
|
readonly terminalWorkstationTag: string;
|
||||||
|
|
@ -251,52 +201,9 @@ export interface TranslationDictionary {
|
||||||
readonly retroGameScoreLabel: string;
|
readonly retroGameScoreLabel: string;
|
||||||
readonly retroGameSave: string;
|
readonly retroGameSave: string;
|
||||||
readonly retroGameSaved: string;
|
readonly retroGameSaved: string;
|
||||||
readonly retroGameWin: string;
|
readonly retroGamePlayAgain: string;
|
||||||
readonly retroGameMines: string;
|
readonly retroGameUp: string;
|
||||||
readonly retroGameFlagMode: string;
|
readonly retroGameLeft: string;
|
||||||
readonly retroGameDigMode: string;
|
readonly retroGameDown: string;
|
||||||
|
readonly retroGameRight: string;
|
||||||
// Cloud Incident Simulator
|
|
||||||
readonly simTag: string;
|
|
||||||
readonly simHeading: string;
|
|
||||||
readonly simSubheading: string;
|
|
||||||
readonly simModeStory: string;
|
|
||||||
readonly simModeTech: string;
|
|
||||||
readonly simEmailLabel: string;
|
|
||||||
readonly simEmailPlaceholder: string;
|
|
||||||
readonly simEmailHint: string;
|
|
||||||
readonly simBtnSimulate: string;
|
|
||||||
readonly simBtnSimulating: string;
|
|
||||||
readonly simBtnReset: string;
|
|
||||||
readonly simStep1Title: string;
|
|
||||||
readonly simStep1Desc: string;
|
|
||||||
readonly simStep2Title: string;
|
|
||||||
readonly simStep2Desc: string;
|
|
||||||
readonly simStep3Title: string;
|
|
||||||
readonly simStep3Desc: string;
|
|
||||||
readonly simStep4Title: string;
|
|
||||||
readonly simStep4Desc: string;
|
|
||||||
readonly simThreatNodeTitle: string;
|
|
||||||
readonly simThreatNodeSub: string;
|
|
||||||
readonly simCloudTrailNodeTitle: string;
|
|
||||||
readonly simCloudTrailNodeSub: string;
|
|
||||||
readonly simLambdaNodeTitle: string;
|
|
||||||
readonly simLambdaNodeSub: string;
|
|
||||||
readonly simSesNodeTitle: string;
|
|
||||||
readonly simSesNodeSub: string;
|
|
||||||
readonly simConsoleTitle: string;
|
|
||||||
readonly simMetricLatency: string;
|
|
||||||
readonly simMetricIntervention: string;
|
|
||||||
readonly simMetricArchitecture: string;
|
|
||||||
readonly simStatusIdle: string;
|
|
||||||
readonly simStatusActive: string;
|
|
||||||
readonly simStatusContained: string;
|
|
||||||
readonly simReportTitle: string;
|
|
||||||
readonly simReportTargetEmail: string;
|
|
||||||
readonly simReportStatusSecure: string;
|
|
||||||
readonly simReportTimeVsAvg: string;
|
|
||||||
readonly simReportCredentialsCompromised: string;
|
|
||||||
readonly simReportFinancialLoss: string;
|
|
||||||
readonly simReportDownloadBtn: string;
|
|
||||||
readonly simReportDemoBadge: string;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
export interface MinesweeperCell {
|
export interface Point {
|
||||||
readonly row: number;
|
readonly x: number;
|
||||||
readonly col: number;
|
readonly y: number;
|
||||||
isMine: boolean;
|
|
||||||
isRevealed: boolean;
|
|
||||||
isFlagged: boolean;
|
|
||||||
isExploded?: boolean;
|
|
||||||
adjacentMines: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MinesweeperFace = 'idle' | 'pressed' | 'dead' | 'won' | 'smile' | 'shock' | 'cool';
|
export type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
|
||||||
|
|
||||||
|
export type GameDifficulty = 'ZEN' | 'STANDARD' | 'TURBO';
|
||||||
|
|
||||||
|
export interface FoodItem {
|
||||||
|
readonly position: Point;
|
||||||
|
readonly kanji: string;
|
||||||
|
readonly points: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetroScore {
|
export interface RetroScore {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|
@ -18,3 +21,11 @@ export interface RetroScore {
|
||||||
readonly createdAtUtc: string;
|
readonly createdAtUtc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
readonly score: number;
|
||||||
|
readonly highScore: number;
|
||||||
|
readonly isGameOver: boolean;
|
||||||
|
readonly isPaused: boolean;
|
||||||
|
readonly isRunning: boolean;
|
||||||
|
readonly difficulty: GameDifficulty;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
export type WindowId = 'games' | 'cv' | 'settings' | 'scores';
|
export type WindowId = 'games' | 'cv' | 'settings' | 'scores';
|
||||||
|
|
||||||
export type OsPowerState = 'off' | 'booting' | 'desktop';
|
|
||||||
|
|
||||||
export type ZenOsTheme = 'zen-dark' | 'retro-amber' | 'matrix-green';
|
export type ZenOsTheme = 'zen-dark' | 'retro-amber' | 'matrix-green';
|
||||||
|
|
||||||
export interface WindowPosition {
|
export interface WindowPosition {
|
||||||
|
|
@ -31,7 +29,7 @@ export interface DesktopIcon {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly windowId: WindowId;
|
readonly windowId: WindowId;
|
||||||
readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy' | 'minesweeper';
|
readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZenOsConfig {
|
export interface ZenOsConfig {
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
export type SimulationMode = 'PURPLE_TEAM' | 'EASM' | 'SOAR';
|
|
||||||
export type SecurityVerdict = 'SAFE' | 'ATTENTION_REQUIRED' | 'CRITICAL_THREAT';
|
|
||||||
|
|
||||||
export interface SecurityAgentParameters {
|
|
||||||
readonly auto_isolate?: boolean;
|
|
||||||
readonly scan_depth?: 'quick' | 'full';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecurityAgentRequest {
|
|
||||||
readonly mode: SimulationMode;
|
|
||||||
readonly target: string;
|
|
||||||
readonly requested_by: string;
|
|
||||||
readonly parameters?: SecurityAgentParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecurityExecutionSummary {
|
|
||||||
readonly risk_score: number;
|
|
||||||
readonly verdict: SecurityVerdict;
|
|
||||||
readonly mitre_techniques: string[];
|
|
||||||
readonly containment_executed: boolean;
|
|
||||||
readonly mitigation_time_ms: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecurityEvidence {
|
|
||||||
readonly sha256_hash: string;
|
|
||||||
readonly report_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecurityAgentResponse {
|
|
||||||
readonly success: boolean;
|
|
||||||
readonly execution_id: string;
|
|
||||||
readonly simulation_id: string;
|
|
||||||
readonly timestamp: string;
|
|
||||||
readonly mode: SimulationMode;
|
|
||||||
readonly target: string;
|
|
||||||
readonly summary: SecurityExecutionSummary;
|
|
||||||
readonly evidence: SecurityEvidence;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { ChatPayload, ChatResponse } from '../models/chat.model';
|
|
||||||
|
|
||||||
export abstract class ChatPort {
|
|
||||||
abstract sendMessage(payload: ChatPayload): Observable<ChatResponse>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { SecurityAgentRequest, SecurityAgentResponse } from '../models/security.model';
|
|
||||||
|
|
||||||
export abstract class SecurityAgentPort {
|
|
||||||
abstract triggerSimulation(payload: SecurityAgentRequest): Observable<SecurityAgentResponse>;
|
|
||||||
}
|
|
||||||
|
|
@ -9,22 +9,22 @@ const INITIAL_SEEDS: RetroScore[] = [
|
||||||
{
|
{
|
||||||
id: 'seed-1',
|
id: 'seed-1',
|
||||||
playerTag: 'ZEN',
|
playerTag: 'ZEN',
|
||||||
gameCode: 'MINESWEEPER',
|
gameCode: 'ZEN_SNAKE',
|
||||||
score: 42,
|
score: 1450,
|
||||||
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'seed-2',
|
id: 'seed-2',
|
||||||
playerTag: 'NEO',
|
playerTag: 'NEO',
|
||||||
gameCode: 'MINESWEEPER',
|
gameCode: 'ZEN_SNAKE',
|
||||||
score: 68,
|
score: 1120,
|
||||||
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'seed-3',
|
id: 'seed-3',
|
||||||
playerTag: 'BRY',
|
playerTag: 'BRY',
|
||||||
gameCode: 'MINESWEEPER',
|
gameCode: 'ZEN_SNAKE',
|
||||||
score: 95,
|
score: 980,
|
||||||
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -54,14 +54,9 @@ export class LocalStorageRetroScoreAdapter implements RetroScorePort {
|
||||||
|
|
||||||
getTopScores(gameCode: string, limit = 10): Observable<RetroScore[]> {
|
getTopScores(gameCode: string, limit = 10): Observable<RetroScore[]> {
|
||||||
const scores = this.readScores();
|
const scores = this.readScores();
|
||||||
const isTimeBased = gameCode === 'MINESWEEPER';
|
|
||||||
const filtered = scores
|
const filtered = scores
|
||||||
.filter(s => s.gameCode === gameCode)
|
.filter(s => s.gameCode === gameCode)
|
||||||
.sort((a, b) =>
|
.sort((a, b) => b.score - a.score || a.createdAtUtc.localeCompare(b.createdAtUtc))
|
||||||
isTimeBased
|
|
||||||
? a.score - b.score || a.createdAtUtc.localeCompare(b.createdAtUtc)
|
|
||||||
: b.score - a.score || a.createdAtUtc.localeCompare(b.createdAtUtc)
|
|
||||||
)
|
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
return of(filtered);
|
return of(filtered);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Observable, map, catchError, throwError } from 'rxjs';
|
|
||||||
import { ChatPort } from '../../domain/ports/chat.port';
|
|
||||||
import { ChatPayload, ChatResponse } from '../../domain/models/chat.model';
|
|
||||||
import { N8nChatRequestDto, N8nChatResponseDto } from '../dtos/n8n-chat.dto';
|
|
||||||
import { CHAT_WEBHOOK_URL } from '../../core/tokens/chat-config.token';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class N8nChatAdapter implements ChatPort {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
private readonly webhookUrl = inject(CHAT_WEBHOOK_URL);
|
|
||||||
|
|
||||||
sendMessage(payload: ChatPayload): Observable<ChatResponse> {
|
|
||||||
const requestDto: N8nChatRequestDto = {
|
|
||||||
message: payload.message,
|
|
||||||
sessionId: payload.sessionId
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.post<N8nChatResponseDto>(this.webhookUrl, requestDto).pipe(
|
|
||||||
map(res => {
|
|
||||||
const replyText = res.reply ?? res.output ?? res.text ?? '';
|
|
||||||
if (!replyText) {
|
|
||||||
throw new Error('Respuesta vacía del asistente.');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
reply: replyText,
|
|
||||||
sessionId: res.sessionId ?? payload.sessionId
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
|
||||||
const errorMsg =
|
|
||||||
err.status === 0
|
|
||||||
? 'No se pudo conectar con el agente de n8n. Por favor verifica que el webhook esté activo.'
|
|
||||||
: err.error?.reply || err.message || 'Error temporal al procesar tu consulta con la IA.';
|
|
||||||
return throwError(() => new Error(errorMsg));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export interface N8nChatRequestDto {
|
|
||||||
readonly message: string;
|
|
||||||
readonly sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface N8nChatResponseDto {
|
|
||||||
readonly reply?: string;
|
|
||||||
readonly output?: string;
|
|
||||||
readonly text?: string;
|
|
||||||
readonly sessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '../domain/models/security.model';
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
:host { display: block; }
|
|
||||||
|
|
||||||
.zen-chat-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 38px;
|
|
||||||
right: 38px;
|
|
||||||
z-index: 1050;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wrapper y Botón Circular Minimalista de Robot Centrado */
|
|
||||||
.launcher-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-pulse-ring {
|
|
||||||
position: absolute;
|
|
||||||
inset: -6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1.5px solid var(--zen-accent-kintsugi);
|
|
||||||
pointer-events: none;
|
|
||||||
animation: zenPulseRing 2.6s cubic-bezier(0.25, 1, 0.5, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes zenPulseRing {
|
|
||||||
0% { transform: scale(0.92); opacity: 0.85; }
|
|
||||||
65%, 100% { transform: scale(1.38); opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-launcher-btn {
|
|
||||||
position: relative;
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(16, 20, 28, 0.94);
|
|
||||||
backdrop-filter: blur(16px) saturate(180%);
|
|
||||||
border: 1.5px solid var(--zen-border-kintsugi);
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.75), 0 0 18px rgba(197, 155, 39, 0.3);
|
|
||||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), background 0.2s ease, box-shadow 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-launcher-btn:hover {
|
|
||||||
transform: translateY(-3px) scale(1.07);
|
|
||||||
background: rgba(22, 28, 38, 0.98);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.85), 0 0 26px rgba(197, 155, 39, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-launcher-btn.active {
|
|
||||||
border-color: var(--zen-border-focus);
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-icon-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-bot-svg {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-close-svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-status-dot {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
right: 2px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--zen-accent-matcha);
|
|
||||||
border: 2px solid var(--zen-bg-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-radar-ping {
|
|
||||||
position: absolute;
|
|
||||||
inset: -2px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--zen-accent-matcha);
|
|
||||||
animation: zenPing 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
@keyframes zenPing { 70%, 100% { transform: scale(2.2); opacity: 0; } }
|
|
||||||
|
|
||||||
.launcher-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
left: -4px;
|
|
||||||
min-width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--zen-accent-kintsugi);
|
|
||||||
color: #000;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ventana de Chat Flotante */
|
|
||||||
.chat-window-card {
|
|
||||||
width: 360px;
|
|
||||||
height: 490px;
|
|
||||||
max-height: calc(100vh - 120px);
|
|
||||||
background: rgba(18, 21, 27, 0.96);
|
|
||||||
backdrop-filter: blur(16px) saturate(160%);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-sm);
|
|
||||||
box-shadow: 0 14px 42px rgba(0, 0, 0, 0.85), 0 0 1px 1px rgba(255, 255, 255, 0.06);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: zenChatEnter 0.24s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes zenChatEnter {
|
|
||||||
from { opacity: 0; transform: translateY(14px) scale(0.95); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: rgba(12, 14, 20, 0.95);
|
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
.header-identity { display: flex; align-items: center; gap: 9px; }
|
|
||||||
.avatar-ring {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--zen-accent-kintsugi);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
background: #090c10;
|
|
||||||
}
|
|
||||||
.header-bot-svg { width: 16px; height: 16px; }
|
|
||||||
.identity-info { display: flex; flex-direction: column; }
|
|
||||||
.identity-name { font-size: 0.8125rem; font-weight: 700; color: var(--zen-text-primary); line-height: 1.2; }
|
|
||||||
.identity-meta { font-size: 0.65rem; font-family: var(--font-mono); color: var(--zen-accent-matcha); }
|
|
||||||
|
|
||||||
.header-actions { display: flex; align-items: center; gap: 3px; }
|
|
||||||
.header-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
padding: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
.header-btn:hover { color: var(--zen-accent-kintsugi); }
|
|
||||||
.action-svg { width: 16px; height: 16px; }
|
|
||||||
|
|
||||||
/* Mensajes */
|
|
||||||
.chat-messages-area { flex: 1; padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 9px; }
|
|
||||||
.message-row { display: flex; width: 100%; animation: zenMsgSlide 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
|
||||||
@keyframes zenMsgSlide { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
|
|
||||||
.user-row { justify-content: flex-end; }
|
|
||||||
.bot-row { justify-content: flex-start; }
|
|
||||||
.message-bubble {
|
|
||||||
max-width: 86%; padding: 8px 12px; border-radius: var(--zen-radius-sm);
|
|
||||||
font-size: 0.8125rem; line-height: 1.45; word-break: break-word;
|
|
||||||
}
|
|
||||||
.user-row .message-bubble { background: var(--zen-bg-elevated); border: 1px solid rgba(197, 155, 39, 0.45); color: var(--zen-text-primary); }
|
|
||||||
.bot-row .message-bubble { background: var(--zen-bg-surface); border: 1px solid var(--zen-border-subtle); color: var(--zen-text-primary); }
|
|
||||||
.error-bubble { border-color: var(--zen-accent-sakura) !important; color: #ff99aa !important; }
|
|
||||||
.message-text { margin: 0; white-space: pre-wrap; }
|
|
||||||
.message-time { display: block; font-size: 0.6rem; font-family: var(--font-mono); color: var(--zen-text-muted); text-align: right; margin-top: 3px; }
|
|
||||||
|
|
||||||
/* Typing & Chips */
|
|
||||||
.typing-bubble { display: flex; align-items: center; gap: 4px; padding: 8px 12px; }
|
|
||||||
.typing-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--zen-accent-kintsugi); animation: typingBlink 1.2s infinite ease-in-out both; }
|
|
||||||
.typing-dot:nth-child(1) { animation-delay: -0.32s; } .typing-dot:nth-child(2) { animation-delay: -0.16s; }
|
|
||||||
@keyframes typingBlink { 0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1.2); } }
|
|
||||||
|
|
||||||
.quick-prompts-group { margin-top: 4px; display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
.quick-title { font-size: 0.65rem; font-family: var(--font-mono); color: var(--zen-text-muted); }
|
|
||||||
.chips-container { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.quick-chip-btn {
|
|
||||||
text-align: left; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-xs); padding: 5px 9px; color: var(--zen-text-secondary);
|
|
||||||
font-size: 0.725rem; cursor: pointer; transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
.quick-chip-btn:hover { border-color: var(--zen-accent-kintsugi); color: var(--zen-text-gold); background: rgba(197, 155, 39, 0.08); }
|
|
||||||
|
|
||||||
/* Input Bar */
|
|
||||||
.chat-input-bar {
|
|
||||||
padding: 7px 9px; background: rgba(12, 14, 20, 0.95); border-top: 1px solid var(--zen-border-subtle);
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
}
|
|
||||||
.chat-text-input {
|
|
||||||
flex: 1; background: var(--zen-bg-surface); border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-xs); padding: 7px 10px; color: var(--zen-text-primary);
|
|
||||||
font-size: 0.8125rem; outline: none;
|
|
||||||
}
|
|
||||||
.chat-text-input:focus { border-color: var(--zen-border-kintsugi); }
|
|
||||||
.chat-send-btn {
|
|
||||||
background: var(--zen-bg-surface); border: 1px solid var(--zen-border-kintsugi); color: var(--zen-accent-kintsugi);
|
|
||||||
border-radius: var(--zen-radius-xs); padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
.chat-send-btn:hover:not(:disabled) { background: var(--zen-accent-kintsugi); color: #000; }
|
|
||||||
.chat-send-btn:disabled { opacity: 0.35; cursor: not-allowed; border-color: var(--zen-border-subtle); color: var(--zen-text-muted); }
|
|
||||||
.send-svg { width: 14px; height: 14px; }
|
|
||||||
|
|
||||||
/* Accesibilidad & Móvil */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.launcher-pulse-ring, .status-radar-ping { animation: none !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.zen-chat-container { bottom: 24px; right: 20px; left: 20px; align-items: stretch; }
|
|
||||||
.launcher-wrapper { align-self: flex-end; }
|
|
||||||
.chat-launcher-btn { width: 52px; height: 52px; }
|
|
||||||
.launcher-bot-svg { width: 26px; height: 26px; }
|
|
||||||
.chat-window-card { width: 100%; height: 430px; max-height: calc(100vh - 100px); }
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
<aside class="zen-chat-container" aria-label="Asistente virtual interactivo">
|
|
||||||
<!-- Botón Disparador Flotante Minimalista (Robot Avatar perfectamente centrado) -->
|
|
||||||
<div class="launcher-wrapper">
|
|
||||||
@if (!store.isOpen()) {
|
|
||||||
<span class="launcher-pulse-ring" aria-hidden="true"></span>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chat-launcher-btn"
|
|
||||||
[class.active]="store.isOpen()"
|
|
||||||
(click)="store.toggleChat()"
|
|
||||||
[attr.aria-expanded]="store.isOpen()"
|
|
||||||
[attr.aria-label]="store.isOpen() ? 'Cerrar asistente virtual' : 'Abrir asistente virtual con IA'"
|
|
||||||
>
|
|
||||||
<div class="launcher-icon-wrapper">
|
|
||||||
@if (store.isOpen()) {
|
|
||||||
<!-- Icono de Minimizar / Cerrar -->
|
|
||||||
<svg class="launcher-close-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
} @else {
|
|
||||||
<!-- Icono de Robot Cyber-Zen Minimalista Centrado -->
|
|
||||||
<svg class="launcher-bot-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 3v4"></path>
|
|
||||||
<circle cx="12" cy="2.5" r="1.2" fill="currentColor"></circle>
|
|
||||||
<rect x="4" y="7" width="16" height="13" rx="3.5" fill="rgba(197, 155, 39, 0.15)"></rect>
|
|
||||||
<circle cx="8.5" cy="12.5" r="1.5" fill="currentColor"></circle>
|
|
||||||
<circle cx="15.5" cy="12.5" r="1.5" fill="currentColor"></circle>
|
|
||||||
<path d="M9 16.5h6"></path>
|
|
||||||
<path d="M2 12.5h2M20 12.5h2"></path>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Indicador de Estado Online (Verde matcha con radar ping) -->
|
|
||||||
<span class="launcher-status-dot" aria-hidden="true">
|
|
||||||
<span class="status-radar-ping"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Badge de Mensajes no Leídos -->
|
|
||||||
@if (!store.isOpen() && store.hasUnread()) {
|
|
||||||
<span class="launcher-badge" aria-label="Mensajes nuevos">{{ store.unreadCount() }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ventana de Chat Flotante Cyber-Zen -->
|
|
||||||
@if (store.isOpen()) {
|
|
||||||
<div class="chat-window-card" role="dialog" aria-modal="true" aria-label="Conversación con asistente">
|
|
||||||
<!-- Cabecera de la Ventana -->
|
|
||||||
<header class="chat-header">
|
|
||||||
<div class="header-identity">
|
|
||||||
<div class="avatar-ring">
|
|
||||||
<svg class="header-bot-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 3v4"></path>
|
|
||||||
<circle cx="12" cy="2.5" r="1.2" fill="currentColor"></circle>
|
|
||||||
<rect x="4" y="7" width="16" height="13" rx="3.5" fill="rgba(197, 155, 39, 0.25)"></rect>
|
|
||||||
<circle cx="8.5" cy="12.5" r="1.5" fill="currentColor"></circle>
|
|
||||||
<circle cx="15.5" cy="12.5" r="1.5" fill="currentColor"></circle>
|
|
||||||
<path d="M9 16.5h6"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="identity-info">
|
|
||||||
<span class="identity-name">Bryan Bot</span>
|
|
||||||
<span class="identity-meta">Online // n8n & Gemini</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="header-btn"
|
|
||||||
(click)="store.clearHistory()"
|
|
||||||
title="Reiniciar conversación"
|
|
||||||
aria-label="Reiniciar conversación"
|
|
||||||
>
|
|
||||||
<svg class="action-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<polyline points="1 4 1 10 7 10"></polyline>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="header-btn"
|
|
||||||
(click)="store.closeChat()"
|
|
||||||
title="Minimizar chat"
|
|
||||||
aria-label="Minimizar chat"
|
|
||||||
>
|
|
||||||
<svg class="action-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Contenedor de Mensajes -->
|
|
||||||
<div #messagesContainer class="chat-messages-area">
|
|
||||||
@for (msg of store.messages(); track msg.id) {
|
|
||||||
<div class="message-row" [class.user-row]="msg.role === 'user'" [class.bot-row]="msg.role === 'assistant'">
|
|
||||||
<div class="message-bubble" [class.error-bubble]="msg.status === 'error'">
|
|
||||||
<p class="message-text">{{ msg.content }}</p>
|
|
||||||
<span class="message-time">{{ msg.timestamp }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Indicador de Escritura -->
|
|
||||||
@if (store.isTyping()) {
|
|
||||||
<div class="message-row bot-row">
|
|
||||||
<div class="message-bubble typing-bubble">
|
|
||||||
<span class="typing-dot"></span>
|
|
||||||
<span class="typing-dot"></span>
|
|
||||||
<span class="typing-dot"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Sugerencias Rápidas -->
|
|
||||||
@if (store.messages().length <= 2 && !store.isTyping()) {
|
|
||||||
<div class="quick-prompts-group">
|
|
||||||
<span class="quick-title">Preguntas sugeridas:</span>
|
|
||||||
<div class="chips-container">
|
|
||||||
@for (prompt of store.quickPrompts(); track prompt) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="quick-chip-btn"
|
|
||||||
(click)="onSelectPrompt(prompt)"
|
|
||||||
>
|
|
||||||
{{ prompt }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Barra de Envío / Input -->
|
|
||||||
<footer class="chat-input-bar">
|
|
||||||
<input
|
|
||||||
#chatInput
|
|
||||||
type="text"
|
|
||||||
class="chat-text-input"
|
|
||||||
placeholder="Escribe una pregunta sobre mi perfil..."
|
|
||||||
maxlength="400"
|
|
||||||
[disabled]="store.isTyping()"
|
|
||||||
(keydown)="onKeyDown($event, chatInput)"
|
|
||||||
aria-label="Mensaje para el asistente"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chat-send-btn"
|
|
||||||
[disabled]="store.isTyping()"
|
|
||||||
(click)="onSend(chatInput)"
|
|
||||||
aria-label="Enviar mensaje"
|
|
||||||
>
|
|
||||||
<svg class="send-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</aside>
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
inject,
|
|
||||||
ViewChild,
|
|
||||||
ElementRef,
|
|
||||||
effect,
|
|
||||||
AfterViewInit
|
|
||||||
} from '@angular/core';
|
|
||||||
import { ChatStore } from '../../../application/stores/chat.store';
|
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-chat-bot',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './chat-bot.component.html',
|
|
||||||
styleUrl: './chat-bot.component.css',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class ChatBotComponent implements AfterViewInit {
|
|
||||||
protected readonly store = inject(ChatStore);
|
|
||||||
protected readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
@ViewChild('messagesContainer') private messagesContainer?: ElementRef<HTMLDivElement>;
|
|
||||||
@ViewChild('chatInput') private chatInput?: ElementRef<HTMLInputElement>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Desplazar automáticamente hacia el final al recibir un mensaje o iniciar respuesta
|
|
||||||
effect(() => {
|
|
||||||
this.store.messages();
|
|
||||||
this.store.isTyping();
|
|
||||||
this.scrollToBottom();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enfocar el input al abrir el chat
|
|
||||||
effect(() => {
|
|
||||||
if (this.store.isOpen()) {
|
|
||||||
setTimeout(() => this.chatInput?.nativeElement?.focus(), 150);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onSend(inputEl: HTMLInputElement): void {
|
|
||||||
const val = inputEl.value;
|
|
||||||
if (!val.trim() || this.store.isTyping()) return;
|
|
||||||
this.store.sendMessage(val);
|
|
||||||
inputEl.value = '';
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onSelectPrompt(prompt: string): void {
|
|
||||||
if (this.store.isTyping()) return;
|
|
||||||
this.store.sendMessage(prompt);
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onKeyDown(event: KeyboardEvent, inputEl: HTMLInputElement): void {
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.onSend(inputEl);
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
this.store.closeChat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollToBottom(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.messagesContainer?.nativeElement) {
|
|
||||||
const el = this.messagesContainer.nativeElement;
|
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, 60);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,222 +3,182 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 1. SECCION PRINCIPAL — Sumi-e Background & Ma (Espacio Negativo) */
|
/* 1. CONTENEDOR PRINCIPAL NORMALIZADO (Fondo #121517 & Padding Amplio) */
|
||||||
.contact-section {
|
.contact-section {
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
padding: clamp(5rem, 8vw, 7rem) 0;
|
padding: clamp(6rem, 10vw, 8rem) 0; /* py-24 sm:py-32 */
|
||||||
overflow: hidden;
|
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
}
|
|
||||||
|
|
||||||
.contact-matrix-canvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
display: block;
|
|
||||||
background: transparent;
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeInMatrix 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInMatrix {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0.65;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-container {
|
.contact-container {
|
||||||
position: relative;
|
max-width: 80rem; /* max-w-7xl */
|
||||||
z-index: 2;
|
|
||||||
max-width: 1540px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 clamp(1rem, 3vw, 2.5rem);
|
padding: 0 1.5rem; /* px-6 */
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cabecera Normalizada de Sección */
|
@media (min-width: 1024px) {
|
||||||
.contact-section-header {
|
.contact-container {
|
||||||
display: flex;
|
padding: 0 2rem; /* lg:px-8 */
|
||||||
align-items: center;
|
}
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
|
||||||
margin-bottom: var(--space-8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-section-header .header-left,
|
|
||||||
.contact-section-header .title-with-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-section-header .pulse-indicator {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 0 12px var(--zen-accent-kintsugi);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-section-header .section-title {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
line-height: 1.15;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. GRID DE CONTENIDO — 2 Columnas Responsive */
|
|
||||||
.contact-grid {
|
.contact-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: clamp(2rem, 5vw, 4rem);
|
gap: 4rem; /* gap-16 */
|
||||||
align-items: start;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.contact-grid {
|
.contact-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: clamp(3rem, 6vw, 5rem);
|
gap: 6rem; /* lg:gap-24 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4. COLUMNA IZQUIERDA — Textos Corporativos */
|
/* 2. COLUMNA IZQUIERDA (Textos en Paleta Zen Dark) */
|
||||||
.contact-info-col {
|
.contact-info-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-eyebrow {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em; /* tracking-widest */
|
||||||
|
color: var(--zen-accent-kintsugi); /* Dorado Kintsugi Normalizado */
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-title {
|
.contact-title {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: clamp(1.75rem, 4vw, 2.75rem);
|
font-size: clamp(2.25rem, 4.5vw, 3.5rem); /* text-5xl o text-6xl */
|
||||||
font-weight: 700;
|
font-weight: 800; /* font-bold */
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
line-height: 1.15;
|
line-height: 1.15; /* leading-tight */
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 var(--space-6) 0;
|
margin: 0 0 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-lead {
|
.contact-lead {
|
||||||
font-size: 0.9375rem;
|
font-size: 1.0625rem; /* text-lg */
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin: 0 0 var(--space-8) 0;
|
margin: 0 0 2rem 0;
|
||||||
max-width: 520px;
|
max-width: 580px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-block {
|
.service-block {
|
||||||
padding-left: var(--space-4);
|
margin-top: 1.5rem;
|
||||||
border-left: 2px solid var(--zen-border-subtle);
|
}
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
|
.service-block:first-of-type {
|
||||||
|
margin-top: 2rem; /* mt-10 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-title {
|
.service-title {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 1rem;
|
font-size: 1.2rem; /* text-xl */
|
||||||
font-weight: 600;
|
font-weight: 700; /* font-bold */
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
margin: 0 0 var(--space-2) 0;
|
margin: 0 0 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-title::before {
|
||||||
|
content: '▹';
|
||||||
|
color: var(--zen-accent-kintsugi);
|
||||||
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-desc {
|
.service-desc {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem; /* text-sm */
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0 0 0 1.25rem;
|
||||||
|
max-width: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. COLUMNA DERECHA (Tarjeta Formulario Zen Dark) */
|
||||||
|
.contact-card-col {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 5. COLUMNA DERECHA — Tarjeta Formulario (Retro Window Frame) */
|
|
||||||
.contact-card {
|
.contact-card {
|
||||||
background-color: var(--zen-bg-surface);
|
background-color: var(--zen-bg-surface); /* #131720 Normalizado */
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border-radius: 1.5rem; /* rounded-3xl */
|
||||||
border-radius: var(--zen-radius-sm);
|
border: 1px solid var(--zen-border-subtle); /* #1e2533 Normalizado */
|
||||||
box-shadow: var(--zen-shadow-window);
|
padding: clamp(2rem, 4vw, 3rem); /* p-8 sm:p-12 */
|
||||||
}
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.75);
|
||||||
|
|
||||||
/* 6. CUERPO DEL FORMULARIO */
|
|
||||||
.contact-card-body {
|
|
||||||
padding: clamp(1.5rem, 3vw, 2rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-header {
|
.form-header {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: 2rem; /* mb-8 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-title {
|
.form-title {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem; /* text-2xl */
|
||||||
font-weight: 600;
|
font-weight: 700; /* font-semibold */
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
margin: 0 0 var(--space-1) 0;
|
margin: 0 0 0.5rem 0; /* mb-2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-subtitle {
|
.form-subtitle {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem; /* text-sm */
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 7. CAMPOS DEL FORMULARIO — Estilo Zen Minimalista */
|
/* Campos del Formulario Normalizados */
|
||||||
.contact-form {
|
.contact-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row-2col {
|
.form-row-2col {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-4);
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.form-row-2col {
|
.form-row-2col {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr; /* sm:grid-cols-2 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.6875rem;
|
font-size: 0.8125rem; /* text-sm */
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input,
|
.form-input,
|
||||||
.form-select,
|
.form-select,
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--zen-bg-base);
|
background-color: var(--zen-bg-base); /* #0c0e14 Normalizado */
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
padding: var(--space-3) var(--space-3);
|
padding: 0.875rem 1rem; /* p-3 o p-4 */
|
||||||
border-radius: var(--zen-radius-xs);
|
border-radius: 0.75rem; /* rounded-xl */
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border: 1px solid var(--zen-border-subtle); /* #1e2533 Normalizado */
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
@ -229,7 +189,6 @@
|
||||||
.form-input::placeholder,
|
.form-input::placeholder,
|
||||||
.form-textarea::placeholder {
|
.form-textarea::placeholder {
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-muted);
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus,
|
.form-input:focus,
|
||||||
|
|
@ -237,12 +196,12 @@
|
||||||
.form-textarea:focus {
|
.form-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--zen-accent-kintsugi);
|
border-color: var(--zen-accent-kintsugi);
|
||||||
box-shadow: 0 0 0 2px rgba(197, 155, 39, 0.2);
|
box-shadow: 0 0 0 2px rgba(197, 155, 39, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 100px;
|
min-height: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-wrapper {
|
.select-wrapper {
|
||||||
|
|
@ -252,11 +211,11 @@
|
||||||
.form-select {
|
.form-select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%235e697d'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239aa5b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right 0.75rem center;
|
background-position: right 1rem center;
|
||||||
background-size: 1rem;
|
background-size: 1.25rem;
|
||||||
padding-right: 2.25rem;
|
padding-right: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-select option {
|
.form-select option {
|
||||||
|
|
@ -264,68 +223,63 @@
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 8. VALIDACION Y ERRORES */
|
|
||||||
.input-error {
|
.input-error {
|
||||||
border-color: var(--zen-accent-sakura) !important;
|
border-color: #f7768e !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-error {
|
.field-error {
|
||||||
font-family: var(--font-mono);
|
font-size: 0.6875rem;
|
||||||
font-size: 0.625rem;
|
color: #f7768e;
|
||||||
color: var(--zen-accent-sakura);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: 0.75rem 1rem;
|
||||||
background-color: rgba(217, 111, 136, 0.08);
|
background-color: rgba(247, 118, 142, 0.1);
|
||||||
border: 1px solid rgba(217, 111, 136, 0.25);
|
border: 1px solid rgba(247, 118, 142, 0.3);
|
||||||
border-radius: var(--zen-radius-xs);
|
border-radius: 0.5rem;
|
||||||
color: var(--zen-accent-sakura);
|
color: #f7768e;
|
||||||
font-size: 0.75rem;
|
font-size: 0.8125rem;
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 9. BOTON DE ENVIO — Kintsugi Flat */
|
/* Botón de Envío Kintsugi Gold */
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width: 100%;
|
width: 100%; /* w-full */
|
||||||
background-color: var(--zen-accent-kintsugi);
|
background: linear-gradient(135deg, var(--zen-accent-kintsugi) 0%, #e2b94a 100%);
|
||||||
color: #07080b;
|
color: #07080b; /* text-black de alto contraste */
|
||||||
font-family: var(--font-mono);
|
font-weight: 700; /* font-semibold */
|
||||||
font-weight: 600;
|
padding: 1rem; /* py-4 */
|
||||||
font-size: 0.8125rem;
|
border-radius: 0.75rem; /* rounded-xl */
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-radius: var(--zen-radius-xs);
|
|
||||||
border: none;
|
border: none;
|
||||||
|
font-size: 0.9375rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
|
box-shadow: 0 4px 16px rgba(197, 155, 39, 0.3);
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-2);
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:hover:not(:disabled) {
|
.submit-btn:hover:not(:disabled) {
|
||||||
background-color: #d4a932;
|
box-shadow: 0 0 24px rgba(197, 155, 39, 0.55);
|
||||||
box-shadow: 0 0 16px rgba(197, 155, 39, 0.35);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:active:not(:disabled) {
|
.submit-btn:active:not(:disabled) {
|
||||||
transform: scale(0.98);
|
transform: scale(0.99);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:disabled {
|
.submit-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.7;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 14px;
|
width: 16px;
|
||||||
height: 14px;
|
height: 16px;
|
||||||
border: 2px solid rgba(7, 8, 11, 0.2);
|
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||||
border-top-color: #07080b;
|
border-top-color: #000000;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 600ms linear infinite;
|
animation: spin 600ms linear infinite;
|
||||||
}
|
}
|
||||||
|
|
@ -334,59 +288,57 @@
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 10. ESTADO DE EXITO */
|
/* Estado de Éxito */
|
||||||
.success-banner {
|
.success-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-8) var(--space-4);
|
padding: 2.5rem 1rem;
|
||||||
gap: var(--space-4);
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-icon-badge {
|
.success-icon-badge {
|
||||||
width: 48px;
|
width: 52px;
|
||||||
height: 48px;
|
height: 52px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: rgba(197, 155, 39, 0.12);
|
background-color: rgba(197, 155, 39, 0.15);
|
||||||
border: 1px solid rgba(197, 155, 39, 0.3);
|
border: 1px solid rgba(197, 155, 39, 0.35);
|
||||||
color: var(--zen-accent-kintsugi);
|
color: var(--zen-accent-kintsugi);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-title {
|
.success-title {
|
||||||
font-family: var(--font-sans);
|
font-size: 1.25rem;
|
||||||
font-size: 1.125rem;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-desc {
|
.success-desc {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem;
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 380px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new-message {
|
.btn-new-message {
|
||||||
margin-top: var(--space-2);
|
margin-top: 0.5rem;
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: var(--zen-radius-xs);
|
color: var(--zen-text-primary);
|
||||||
color: var(--zen-text-secondary);
|
padding: 0.625rem 1.25rem;
|
||||||
font-family: var(--font-mono);
|
border-radius: 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.8125rem;
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
transition: background-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new-message:hover {
|
.btn-new-message:hover {
|
||||||
border-color: var(--zen-border-focus);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,24 @@
|
||||||
<section class="contact-section" id="contacto" role="region" [attr.aria-label]="i18n.t().contactEyebrow">
|
<section class="contact-section" id="contacto">
|
||||||
<!-- Fondo Animado: Lluvia de Código Matrix Verde -->
|
|
||||||
<canvas #rainCanvas class="contact-matrix-canvas" aria-hidden="true"></canvas>
|
|
||||||
|
|
||||||
<div class="contact-container">
|
<div class="contact-container">
|
||||||
<!-- Cabecera Normalizada de Sección -->
|
|
||||||
<header class="contact-section-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<div class="title-with-badge">
|
|
||||||
<span class="pulse-indicator" aria-hidden="true"></span>
|
|
||||||
<h2 class="section-title">{{ i18n.t().contactEyebrow }}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="contact-grid">
|
<div class="contact-grid">
|
||||||
<!-- 1. COLUMNA IZQUIERDA (Textos Corporativos & Servicios) -->
|
<!-- 1. COLUMNA IZQUIERDA (Textos Corporativos & Servicios) -->
|
||||||
<div class="contact-info-col">
|
<div class="contact-info-col">
|
||||||
|
<!-- Eyebrow -->
|
||||||
|
<span class="contact-eyebrow">
|
||||||
|
{{ i18n.t().contactEyebrow }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Título Principal H2 -->
|
||||||
<h2 class="contact-title">
|
<h2 class="contact-title">
|
||||||
{{ i18n.t().contactTitle }}
|
{{ i18n.t().contactTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<!-- Párrafo Principal -->
|
||||||
<p class="contact-lead">
|
<p class="contact-lead">
|
||||||
{{ i18n.t().contactLead }}
|
{{ i18n.t().contactLead }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Bloque de Servicio 1 -->
|
||||||
<div class="service-block">
|
<div class="service-block">
|
||||||
<h3 class="service-title">{{ i18n.t().contactService1Title }}</h3>
|
<h3 class="service-title">{{ i18n.t().contactService1Title }}</h3>
|
||||||
<p class="service-desc">
|
<p class="service-desc">
|
||||||
|
|
@ -31,6 +26,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bloque de Servicio 2 -->
|
||||||
<div class="service-block">
|
<div class="service-block">
|
||||||
<h3 class="service-title">{{ i18n.t().contactService2Title }}</h3>
|
<h3 class="service-title">{{ i18n.t().contactService2Title }}</h3>
|
||||||
<p class="service-desc">
|
<p class="service-desc">
|
||||||
|
|
@ -40,11 +36,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. COLUMNA DERECHA (Tarjeta Formulario) -->
|
<!-- 2. COLUMNA DERECHA (Tarjeta Formulario) -->
|
||||||
<div class="contact-card">
|
<div class="contact-card-col">
|
||||||
<div class="contact-card-body">
|
<div class="contact-card">
|
||||||
|
<!-- Cabecera del Formulario -->
|
||||||
|
<div class="form-header">
|
||||||
|
<h3 class="form-title">{{ i18n.t().contactFormTitle }}</h3>
|
||||||
|
<p class="form-subtitle">
|
||||||
|
{{ i18n.t().contactFormSubtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (isSuccess()) {
|
@if (isSuccess()) {
|
||||||
|
<!-- Estado de Éxito al Enviar -->
|
||||||
<div class="success-banner" role="alert">
|
<div class="success-banner" role="alert">
|
||||||
<div class="success-icon-badge" aria-hidden="true">✓</div>
|
<div class="success-icon-badge">✔</div>
|
||||||
<h4 class="success-title">{{ i18n.t().contactSuccessTitle }}</h4>
|
<h4 class="success-title">{{ i18n.t().contactSuccessTitle }}</h4>
|
||||||
<p class="success-desc">
|
<p class="success-desc">
|
||||||
{{ i18n.t().contactSuccessDesc }}
|
{{ i18n.t().contactSuccessDesc }}
|
||||||
|
|
@ -54,14 +59,9 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="form-header">
|
<!-- Formulario Reactivo -->
|
||||||
<h3 class="form-title">{{ i18n.t().contactFormTitle }}</h3>
|
|
||||||
<p class="form-subtitle">
|
|
||||||
{{ i18n.t().contactFormSubtitle }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form [formGroup]="contactForm" (ngSubmit)="onSubmit()" novalidate class="contact-form">
|
<form [formGroup]="contactForm" (ngSubmit)="onSubmit()" novalidate class="contact-form">
|
||||||
|
<!-- Fila 1: Nombre o Empresa (100% ancho) -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fullNameOrCompany" class="form-label">{{ i18n.t().contactLabelName }}</label>
|
<label for="fullNameOrCompany" class="form-label">{{ i18n.t().contactLabelName }}</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 2: Grid de 2 Columnas (Email y Teléfono) -->
|
||||||
<div class="form-row-2col">
|
<div class="form-row-2col">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email" class="form-label">{{ i18n.t().contactLabelEmail }}</label>
|
<label for="email" class="form-label">{{ i18n.t().contactLabelEmail }}</label>
|
||||||
|
|
@ -115,6 +116,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 3: Select Servicio (100% ancho) -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="serviceType" class="form-label">{{ i18n.t().contactLabelService }}</label>
|
<label for="serviceType" class="form-label">{{ i18n.t().contactLabelService }}</label>
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
|
|
@ -128,6 +130,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 4: Textarea Detalles (100% ancho, mínimo 4 filas) -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="projectDetails" class="form-label">
|
<label for="projectDetails" class="form-label">
|
||||||
{{ i18n.t().contactLabelDetails }}
|
{{ i18n.t().contactLabelDetails }}
|
||||||
|
|
@ -151,12 +154,14 @@
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje de Error Global si aplica -->
|
||||||
@if (errorMessage()) {
|
@if (errorMessage()) {
|
||||||
<div class="error-banner" role="alert">
|
<div class="error-banner" role="alert">
|
||||||
{{ errorMessage() }}
|
{{ errorMessage() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Botón de Envío -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="submit-btn"
|
class="submit-btn"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,4 @@
|
||||||
import {
|
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
|
||||||
Component,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
signal,
|
|
||||||
inject,
|
|
||||||
ElementRef,
|
|
||||||
ViewChild,
|
|
||||||
OnInit,
|
|
||||||
AfterViewInit,
|
|
||||||
OnDestroy
|
|
||||||
} from '@angular/core';
|
|
||||||
import {
|
import {
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
|
|
@ -17,20 +7,6 @@ import {
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
interface DigitalRainColumn {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
speed: number;
|
|
||||||
length: number;
|
|
||||||
opacity: number;
|
|
||||||
characters: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const BINARY_CHARS = '01';
|
|
||||||
const HEX_CHARS = '0123456789ABCDEF';
|
|
||||||
const CODE_CHARS = '{}[]()<>&|/\\:;=+-_*#$%@?!~^';
|
|
||||||
const TERM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-contact-section',
|
selector: 'app-contact-section',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -39,9 +15,7 @@ const TERM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||||
styleUrl: './contact-section.component.css',
|
styleUrl: './contact-section.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ContactSectionComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class ContactSectionComponent {
|
||||||
@ViewChild('rainCanvas') private readonly rainCanvas?: ElementRef<HTMLCanvasElement>;
|
|
||||||
|
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
// Estado reactivo del formulario con Signals
|
// Estado reactivo del formulario con Signals
|
||||||
|
|
@ -71,191 +45,6 @@ export class ContactSectionComponent implements OnInit, AfterViewInit, OnDestroy
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly columns: DigitalRainColumn[] = [];
|
|
||||||
private context?: CanvasRenderingContext2D;
|
|
||||||
private animationFrameId?: number;
|
|
||||||
private intersectionObserver?: IntersectionObserver;
|
|
||||||
private resizeObserver?: ResizeObserver;
|
|
||||||
private motionQuery?: MediaQueryList;
|
|
||||||
private isVisible = true;
|
|
||||||
private isReducedMotion = false;
|
|
||||||
private lastFrameTime = 0;
|
|
||||||
|
|
||||||
ngOnInit(): void {}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
if (!this.rainCanvas) return;
|
|
||||||
|
|
||||||
const canvas = this.rainCanvas.nativeElement;
|
|
||||||
this.context = canvas.getContext('2d') ?? undefined;
|
|
||||||
if (!this.context) return;
|
|
||||||
|
|
||||||
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
this.isReducedMotion = this.motionQuery.matches;
|
|
||||||
this.motionQuery.addEventListener('change', this.onMotionPreferenceChange);
|
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
|
|
||||||
this.resizeObserver.observe(canvas.parentElement ?? canvas);
|
|
||||||
|
|
||||||
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
|
||||||
this.isVisible = entry.isIntersecting;
|
|
||||||
if (this.isVisible && !this.isReducedMotion && this.animationFrameId === undefined) {
|
|
||||||
this.lastFrameTime = 0;
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
}
|
|
||||||
}, { threshold: 0.05 });
|
|
||||||
this.intersectionObserver.observe(canvas);
|
|
||||||
|
|
||||||
this.resizeCanvas();
|
|
||||||
if (!this.isReducedMotion) {
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
if (this.animationFrameId !== undefined) {
|
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
}
|
|
||||||
this.motionQuery?.removeEventListener('change', this.onMotionPreferenceChange);
|
|
||||||
this.intersectionObserver?.disconnect();
|
|
||||||
this.resizeObserver?.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly onMotionPreferenceChange = (event: MediaQueryListEvent): void => {
|
|
||||||
this.isReducedMotion = event.matches;
|
|
||||||
if (this.isReducedMotion) {
|
|
||||||
if (this.animationFrameId !== undefined) {
|
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
this.animationFrameId = undefined;
|
|
||||||
}
|
|
||||||
this.draw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isVisible && this.animationFrameId === undefined) {
|
|
||||||
this.lastFrameTime = 0;
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly animate = (timestamp: number): void => {
|
|
||||||
this.animationFrameId = undefined;
|
|
||||||
if (!this.isVisible || this.isReducedMotion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = this.lastFrameTime === 0 ? 0 : Math.min(timestamp - this.lastFrameTime, 50);
|
|
||||||
this.lastFrameTime = timestamp;
|
|
||||||
this.update(elapsed / 1000);
|
|
||||||
this.draw();
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
private resizeCanvas(): void {
|
|
||||||
if (!this.rainCanvas) return;
|
|
||||||
|
|
||||||
const canvas = this.rainCanvas.nativeElement;
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
const bounds = parent ? parent.getBoundingClientRect() : canvas.getBoundingClientRect();
|
|
||||||
const width = Math.max(bounds.width, window.innerWidth || 320);
|
|
||||||
const height = Math.max(bounds.height, 700);
|
|
||||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
|
|
||||||
canvas.width = Math.max(1, Math.floor(width * pixelRatio));
|
|
||||||
canvas.height = Math.max(1, Math.floor(height * pixelRatio));
|
|
||||||
this.context?.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
this.createColumns(width, height);
|
|
||||||
this.draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private createColumns(width: number, height: number): void {
|
|
||||||
const fontSize = Math.max(14, Math.min(20, width / 58));
|
|
||||||
const columnCount = Math.max(24, Math.floor(width / (fontSize * 0.72)));
|
|
||||||
this.columns.length = 0;
|
|
||||||
|
|
||||||
for (let index = 0; index < columnCount; index += 1) {
|
|
||||||
const length = Math.floor(Math.random() * 25) + 14;
|
|
||||||
this.columns.push({
|
|
||||||
x: (index / columnCount) * width + Math.random() * fontSize * 0.6,
|
|
||||||
y: -Math.random() * (height * 0.9) - length * fontSize,
|
|
||||||
speed: Math.floor(Math.random() * 130) + 150,
|
|
||||||
length,
|
|
||||||
opacity: 0.25 + Math.random() * 0.65,
|
|
||||||
characters: Array.from({ length }, () => this.randomCharacter())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private update(deltaSeconds: number): void {
|
|
||||||
if (!this.rainCanvas) return;
|
|
||||||
const parent = this.rainCanvas.nativeElement.parentElement;
|
|
||||||
const height = parent ? parent.clientHeight : this.rainCanvas.nativeElement.clientHeight;
|
|
||||||
const width = parent ? parent.clientWidth : this.rainCanvas.nativeElement.clientWidth;
|
|
||||||
const fontSize = Math.max(14, Math.min(20, width / 58));
|
|
||||||
|
|
||||||
for (const column of this.columns) {
|
|
||||||
column.y += column.speed * deltaSeconds;
|
|
||||||
if (column.y - column.length * fontSize > height) {
|
|
||||||
column.y = -Math.random() * height * 0.75 - column.length * fontSize;
|
|
||||||
column.speed = Math.floor(Math.random() * 130) + 150;
|
|
||||||
column.length = Math.floor(Math.random() * 25) + 14;
|
|
||||||
column.opacity = 0.25 + Math.random() * 0.65;
|
|
||||||
column.characters = Array.from({ length: column.length }, () => this.randomCharacter());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private draw(): void {
|
|
||||||
if (!this.rainCanvas || !this.context) return;
|
|
||||||
|
|
||||||
const canvas = this.rainCanvas.nativeElement;
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
const width = parent ? parent.clientWidth : canvas.clientWidth;
|
|
||||||
const height = parent ? parent.clientHeight : canvas.clientHeight;
|
|
||||||
const fontSize = Math.max(14, Math.min(20, width / 58));
|
|
||||||
|
|
||||||
this.context.clearRect(0, 0, width, height);
|
|
||||||
this.context.font = `${fontSize}px monospace`;
|
|
||||||
this.context.textAlign = 'center';
|
|
||||||
this.context.textBaseline = 'top';
|
|
||||||
this.context.shadowBlur = 0;
|
|
||||||
|
|
||||||
for (const column of this.columns) {
|
|
||||||
for (let i = 0; i < column.characters.length; i++) {
|
|
||||||
const charY = column.y + i * fontSize;
|
|
||||||
if (charY < -fontSize || charY > height) continue;
|
|
||||||
|
|
||||||
const isHead = i === 0;
|
|
||||||
const isHighlight = i === Math.floor(column.length * 0.35) && column.opacity > 0.4;
|
|
||||||
const trailAlpha = column.opacity * (1 - i / (column.length * 1.15));
|
|
||||||
const edgeFade = Math.min(1, Math.max(0, (height - charY) / (fontSize * 5)));
|
|
||||||
const alpha = Math.max(0, trailAlpha * edgeFade);
|
|
||||||
|
|
||||||
if (alpha <= 0) continue;
|
|
||||||
|
|
||||||
this.context.fillStyle = isHead
|
|
||||||
? `rgba(220, 255, 226, ${Math.min(1, alpha + 0.35)})`
|
|
||||||
: isHighlight
|
|
||||||
? `rgba(80, 255, 126, ${alpha})`
|
|
||||||
: `rgba(18, 174, 78, ${alpha})`;
|
|
||||||
|
|
||||||
if (isHead || isHighlight) {
|
|
||||||
this.context.shadowColor = 'rgba(37, 255, 104, 0.85)';
|
|
||||||
this.context.shadowBlur = isHead ? 9 : 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.context.fillText(column.characters[i], column.x, charY);
|
|
||||||
this.context.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private randomCharacter(): string {
|
|
||||||
const r = Math.random();
|
|
||||||
const set = r < 0.48 ? BINARY_CHARS : r < 0.68 ? HEX_CHARS : r < 0.72 ? CODE_CHARS : TERM_CHARS;
|
|
||||||
return set[Math.floor(Math.random() * set.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.contactForm.invalid) {
|
if (this.contactForm.invalid) {
|
||||||
this.contactForm.markAllAsTouched();
|
this.contactForm.markAllAsTouched();
|
||||||
|
|
@ -266,6 +55,7 @@ export class ContactSectionComponent implements OnInit, AfterViewInit, OnDestroy
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
this.isSubmitting.set(true);
|
this.isSubmitting.set(true);
|
||||||
|
|
||||||
|
// Simulación de envío con confirmación visual inmediata
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.isSubmitting.set(false);
|
this.isSubmitting.set(false);
|
||||||
this.isSuccess.set(true);
|
this.isSuccess.set(true);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-projects-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: var(--space-4);
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Micro-Cabecera de la sección */
|
||||||
|
.section-micro-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: rgba(18, 22, 28, 0.6);
|
||||||
|
border: 1px solid var(--zen-border-subtle);
|
||||||
|
border-radius: var(--zen-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-indicator {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--zen-accent-kintsugi);
|
||||||
|
box-shadow: 0 0 8px var(--zen-accent-kintsugi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tag {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-count {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--zen-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid de 3 columnas para los proyectos */
|
||||||
|
.projects-stack {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.projects-stack {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tarjeta individual de proyecto */
|
||||||
|
.project-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background-color: rgba(14, 18, 24, 0.75);
|
||||||
|
border: 1px solid var(--zen-border-subtle);
|
||||||
|
border-top: 3px solid var(--prj-accent, var(--zen-accent-kintsugi));
|
||||||
|
border-radius: var(--zen-radius-xs);
|
||||||
|
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
background-color: rgba(18, 24, 32, 0.95);
|
||||||
|
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6), 0 0 16px rgba(125, 207, 255, 0.05);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Espacio reservado para la imagen del proyecto (Banner superior de la card) */
|
||||||
|
.project-image-slot {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
background-color: #090b0e;
|
||||||
|
border: 1px solid var(--zen-border-subtle);
|
||||||
|
border-radius: var(--zen-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marcador de posición técnico cuando aún no hay archivo subido */
|
||||||
|
.image-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.03) 0%, transparent 80%);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--zen-text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-code {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--zen-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-label {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--zen-text-muted);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contenido informativo del proyecto */
|
||||||
|
.project-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.institution-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-bullet {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-institution {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--zen-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-tag {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--zen-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-impact {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--zen-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fila de chips tecnológicos */
|
||||||
|
.project-tags-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-chip {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--zen-text-muted);
|
||||||
|
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover .tech-chip {
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<div class="featured-projects-wrapper" role="region" [attr.aria-label]="i18n.t().fpAriaLabel">
|
||||||
|
<!-- Cabecera de la sección -->
|
||||||
|
<header class="section-micro-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="pulse-indicator" aria-hidden="true"></span>
|
||||||
|
<span class="header-tag">{{ i18n.t().fpHeaderTag }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Lista de las 3 Tarjetas de Proyecto -->
|
||||||
|
<div class="projects-stack">
|
||||||
|
@for (prj of projects(); track prj.id) {
|
||||||
|
<article class="project-card" [style.--prj-accent]="prj.accentColor">
|
||||||
|
<!-- Espacio reservado para la imagen del proyecto -->
|
||||||
|
<div class="project-image-slot">
|
||||||
|
@if (!imageErrors()[prj.id] && prj.imageUrl) {
|
||||||
|
<img [src]="prj.imageUrl" [alt]="i18n.t().fpAltPrefix + prj.title" class="project-img"
|
||||||
|
(error)="onImageError(prj.id)" loading="lazy" />
|
||||||
|
} @else {
|
||||||
|
<div class="image-placeholder">
|
||||||
|
<span class="placeholder-icon"></span>
|
||||||
|
<span class="placeholder-code">{{ prj.code }}</span>
|
||||||
|
<span class="placeholder-label">{{ i18n.t().fpPlaceholderImage }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido descriptivo del proyecto -->
|
||||||
|
<div class="project-content">
|
||||||
|
<!-- Meta superior -->
|
||||||
|
<div class="project-meta-row">
|
||||||
|
<div class="institution-badge">
|
||||||
|
<span class="meta-bullet">●</span>
|
||||||
|
<span class="meta-institution">{{ prj.institution }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="meta-tag">{{ prj.subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Título principal -->
|
||||||
|
<h3 class="project-title">{{ prj.title }}</h3>
|
||||||
|
|
||||||
|
<!-- Impacto y resultados clave extraídos del CV -->
|
||||||
|
<p class="project-impact">{{ prj.impact }}</p>
|
||||||
|
|
||||||
|
<!-- Chips de Tecnologías -->
|
||||||
|
<div class="project-tags-row">
|
||||||
|
@for (tag of prj.tags; track tag) {
|
||||||
|
<span class="tech-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Component, ChangeDetectionStrategy, signal, computed, inject } from '@angular/core';
|
||||||
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
|
export interface ProjectCardData {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly subtitle: string;
|
||||||
|
readonly institution: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly impact: string;
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
readonly imageUrl?: string;
|
||||||
|
readonly accentColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-featured-projects',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './featured-projects.component.html',
|
||||||
|
styleUrl: './featured-projects.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class FeaturedProjectsComponent {
|
||||||
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
|
// Proyectos extraídos directamente de la hoja de vida (CV) de Bryan Baño con soporte i18n
|
||||||
|
protected readonly projects = computed<ProjectCardData[]>(() => {
|
||||||
|
const t = this.i18n.t();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'siplece',
|
||||||
|
code: 'PRJ-01',
|
||||||
|
title: 'SIPLECE',
|
||||||
|
subtitle: t.prj1Subtitle,
|
||||||
|
institution: 'IST Mayor Pedro Traversari',
|
||||||
|
description: t.prj1Desc,
|
||||||
|
impact: t.prj1Impact,
|
||||||
|
tags: ['Angular', '.NET 8', 'MySQL', 'JWT', 'SharePoint'],
|
||||||
|
imageUrl: 'assets/images/projects/siplece.webp',
|
||||||
|
accentColor: '#7dcfff'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vita',
|
||||||
|
code: 'PRJ-02',
|
||||||
|
title: 'VITA Automotriz',
|
||||||
|
subtitle: t.prj2Subtitle,
|
||||||
|
institution: t.prj2Institution,
|
||||||
|
description: t.prj2Desc,
|
||||||
|
impact: t.prj2Impact,
|
||||||
|
tags: ['Angular', '.NET 8', 'MySQL', 'REST API'],
|
||||||
|
imageUrl: 'assets/images/projects/vita.webp',
|
||||||
|
accentColor: '#9ece6a'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ammi',
|
||||||
|
code: 'PRJ-03',
|
||||||
|
title: 'Ammi-Online',
|
||||||
|
subtitle: t.prj3Subtitle,
|
||||||
|
institution: t.prj3Institution,
|
||||||
|
description: t.prj3Desc,
|
||||||
|
impact: t.prj3Impact,
|
||||||
|
tags: ['Angular', '.NET 8', 'MySQL', 'Clean Arch'],
|
||||||
|
imageUrl: 'assets/images/projects/ammi.webp',
|
||||||
|
accentColor: '#bb9af7'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manejo de fallback elegante si el usuario aún no ha colocado los archivos de imagen
|
||||||
|
protected readonly imageErrors = signal<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
onImageError(projectId: string): void {
|
||||||
|
this.imageErrors.update(curr => ({ ...curr, [projectId]: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,451 +0,0 @@
|
||||||
/* Featured Projects Component Styles */
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-projects-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
gap: var(--space-4);
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cabecera limpia y abierta de la sección de Proyectos */
|
|
||||||
.projects-section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-with-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-indicator {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 0 12px var(--zen-accent-kintsugi);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
line-height: 1.15;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-nav-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-nav-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-nav-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
border-color: var(--zen-accent-kintsugi);
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
transform: scale(1.08);
|
|
||||||
box-shadow: 0 0 12px rgba(224, 175, 104, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-nav-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cuadrícula de Proyectos en Carrusel (Ocupa 100% de ancho) */
|
|
||||||
.projects-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-5);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.projects-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.projects-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(16, 20, 26, 0.94), rgba(12, 16, 22, 0.9)),
|
|
||||||
rgba(11, 15, 18, 0.88);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-md);
|
|
||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
||||||
animation: cardFadeIn 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cardFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0.55;
|
|
||||||
transform: translateY(4px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card:hover,
|
|
||||||
.project-card:focus-visible {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
border-color: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.28), 0 0 16px rgba(224, 175, 104, 0.12);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card.is-selected {
|
|
||||||
border-color: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.32), 0 0 20px rgba(224, 175, 104, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-image-slot {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
background-color: #080b0f;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-image-slot.no-image {
|
|
||||||
background: #080c14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tech-blueprint-slot {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%),
|
|
||||||
#080b12;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-grid-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(125, 207, 255, 0.08) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(125, 207, 255, 0.08) 1px, transparent 1px);
|
|
||||||
background-size: 16px 16px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-icon {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-code {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-title {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-spec {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-dev-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 2;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
background: rgba(18, 22, 32, 0.88);
|
|
||||||
border: 1px solid rgba(187, 154, 247, 0.4);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: #bb9af7;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-dev-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #bb9af7;
|
|
||||||
box-shadow: 0 0 6px #bb9af7;
|
|
||||||
animation: pulse-dev 2s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-dev {
|
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
|
||||||
50% { opacity: 0.4; transform: scale(0.8); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.institution-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-bullet {
|
|
||||||
font-size: 0.5rem;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-institution {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-tag {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--zen-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-title {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-impact {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-tags-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tech-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.55rem;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.67rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-action {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card:hover .project-action {
|
|
||||||
color: #f7cb78;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Detalle */
|
|
||||||
.project-detail-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: clamp(1rem, 2vw, 2.5rem);
|
|
||||||
background: rgba(5, 7, 10, 0.85);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-shell {
|
|
||||||
width: min(1300px, 100%);
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow: auto;
|
|
||||||
border-radius: var(--zen-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dots Indicadores */
|
|
||||||
.carousel-dots-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot .dot-inner {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot:hover .dot-inner {
|
|
||||||
background: rgba(255, 255, 255, 0.45);
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot.is-active .dot-inner {
|
|
||||||
width: 24px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 0 10px var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.project-card {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
<div class="featured-projects-wrapper" role="region" [attr.aria-label]="i18n.t().fpAriaLabel">
|
|
||||||
<!-- Cabecera limpia y prominente de la sección con controles del carrusel -->
|
|
||||||
<header class="projects-section-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<div class="title-with-badge">
|
|
||||||
<span class="pulse-indicator" aria-hidden="true"></span>
|
|
||||||
<h2 class="section-title">{{ i18n.t().fpHeaderTag }}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="carousel-nav-controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-nav-btn"
|
|
||||||
(click)="prev()"
|
|
||||||
[attr.aria-label]="i18n.t().fpCarouselPrev"
|
|
||||||
title="Anterior"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2">
|
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-nav-btn"
|
|
||||||
(click)="next()"
|
|
||||||
[attr.aria-label]="i18n.t().fpCarouselNext"
|
|
||||||
title="Siguiente"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2">
|
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Cuadrícula de Proyectos en Carrusel (Ocupa todo el espacio horizontal) -->
|
|
||||||
<div class="projects-grid">
|
|
||||||
@for (prj of visibleProjects(); track prj.id) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="project-card"
|
|
||||||
[class.is-selected]="selectedProjectId() === prj.id"
|
|
||||||
[attr.aria-pressed]="selectedProjectId() === prj.id"
|
|
||||||
(click)="selectProject(prj.id)"
|
|
||||||
>
|
|
||||||
<div class="project-image-slot" [class.no-image]="!prj.imageUrl">
|
|
||||||
@if (prj.imageUrl && !imageErrors()[prj.imageUrl]) {
|
|
||||||
<img
|
|
||||||
[src]="prj.imageUrl"
|
|
||||||
[alt]="i18n.t().fpAltPrefix + prj.title"
|
|
||||||
class="project-img"
|
|
||||||
(error)="onImageError(prj.imageUrl)"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<!-- Diseño Blueprint / Ficha Técnica para SIPLECE o proyectos sin capturas -->
|
|
||||||
<div class="tech-blueprint-slot">
|
|
||||||
<div class="blueprint-grid-overlay" aria-hidden="true"></div>
|
|
||||||
<div class="blueprint-content">
|
|
||||||
<span class="blueprint-icon">◧</span>
|
|
||||||
<span class="blueprint-code">{{ prj.code }}</span>
|
|
||||||
<span class="blueprint-title">{{ prj.title }}</span>
|
|
||||||
<span class="blueprint-spec">{{ i18n.t().fpNoImagesTechnical }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (prj.inDevelopment) {
|
|
||||||
<div class="in-dev-badge">
|
|
||||||
<span class="in-dev-dot"></span>
|
|
||||||
<span>{{ i18n.t().fpInDevelopment }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-content">
|
|
||||||
<div class="project-meta-row">
|
|
||||||
<div class="institution-badge">
|
|
||||||
<span class="meta-bullet">●</span>
|
|
||||||
<span class="meta-institution">{{ prj.institution }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="meta-tag">{{ prj.subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="project-title">{{ prj.title }}</h3>
|
|
||||||
<p class="project-impact">{{ prj.impact }}</p>
|
|
||||||
|
|
||||||
<div class="project-tags-row">
|
|
||||||
@for (tag of prj.tags; track tag) {
|
|
||||||
<span class="tech-chip">{{ tag }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="project-action">
|
|
||||||
{{ selectedProjectId() === prj.id ? 'Detalle abierto' : 'Ver detalle' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Indicadores de página (Dots) -->
|
|
||||||
@if (totalPages() > 1) {
|
|
||||||
<div class="carousel-dots-row" role="tablist" aria-label="Selector de proyectos">
|
|
||||||
@for (pageIdx of pagesArray(); track pageIdx) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-dot"
|
|
||||||
[class.is-active]="currentIndex() === pageIdx"
|
|
||||||
(click)="goToPage(pageIdx)"
|
|
||||||
[attr.aria-label]="'Página ' + (pageIdx + 1)"
|
|
||||||
[attr.aria-selected]="currentIndex() === pageIdx"
|
|
||||||
>
|
|
||||||
<span class="dot-inner"></span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Modal Detalle del Proyecto Seleccionado -->
|
|
||||||
@if (selectedProject(); as selected) {
|
|
||||||
<div class="project-detail-overlay" (click)="clearSelection()">
|
|
||||||
<div class="project-detail-shell" (click)="$event.stopPropagation()">
|
|
||||||
<app-project-detail [project]="selected" (close)="clearSelection()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
signal,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
HostListener
|
|
||||||
} from '@angular/core';
|
|
||||||
import { I18nService } from '../../../../application/services/i18n.service';
|
|
||||||
import { ProjectDetailComponent } from '../project-detail/project-detail.component';
|
|
||||||
|
|
||||||
export interface ProjectCardData {
|
|
||||||
readonly id: string;
|
|
||||||
readonly code: string;
|
|
||||||
readonly title: string;
|
|
||||||
readonly subtitle: string;
|
|
||||||
readonly institution: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly impact: string;
|
|
||||||
readonly challenge: string;
|
|
||||||
readonly solution: string;
|
|
||||||
readonly results: readonly string[];
|
|
||||||
readonly tags: readonly string[];
|
|
||||||
readonly imageUrl?: string;
|
|
||||||
readonly images?: readonly string[];
|
|
||||||
readonly accentColor: string;
|
|
||||||
readonly inDevelopment?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-featured-projects',
|
|
||||||
standalone: true,
|
|
||||||
imports: [ProjectDetailComponent],
|
|
||||||
templateUrl: './featured-projects.component.html',
|
|
||||||
styleUrl: './featured-projects.component.css',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class FeaturedProjectsComponent {
|
|
||||||
protected readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
// Navegación de cuadrícula limpia con carrusel responsivo
|
|
||||||
protected readonly currentIndex = signal<number>(0);
|
|
||||||
protected readonly screenCols = signal<number>(
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? window.innerWidth >= 1200
|
|
||||||
? 3
|
|
||||||
: window.innerWidth >= 768
|
|
||||||
? 2
|
|
||||||
: 1
|
|
||||||
: 3
|
|
||||||
);
|
|
||||||
|
|
||||||
@HostListener('window:resize')
|
|
||||||
onResize(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const w = window.innerWidth;
|
|
||||||
const cols = w >= 1200 ? 3 : w >= 768 ? 2 : 1;
|
|
||||||
if (this.screenCols() !== cols) {
|
|
||||||
this.screenCols.set(cols);
|
|
||||||
if (this.currentIndex() > this.maxIndex()) {
|
|
||||||
this.currentIndex.set(this.maxIndex());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proyectos reales de Bryan Baño con arquitectura limpia y soporte i18n
|
|
||||||
protected readonly projects = computed<ProjectCardData[]>(() => {
|
|
||||||
const t = this.i18n.t();
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'ammi',
|
|
||||||
code: 'PRJ-01',
|
|
||||||
title: 'Ammi-Online',
|
|
||||||
subtitle: t.prj2Subtitle,
|
|
||||||
institution: t.prj2Institution,
|
|
||||||
description: t.prj2Desc,
|
|
||||||
impact: t.prj2Impact,
|
|
||||||
challenge: t.prj2Challenge,
|
|
||||||
solution: t.prj2Solution,
|
|
||||||
results: [t.prj2Result1, t.prj2Result2, t.prj2Result3],
|
|
||||||
tags: ['Angular', '.NET 8', 'Clean Arch', 'JWT & RBAC', 'MediatR CQRS'],
|
|
||||||
imageUrl: 'assets/images/projects/ammi/ammi1.jpg',
|
|
||||||
images: [
|
|
||||||
'assets/images/projects/ammi/ammi1.jpg',
|
|
||||||
'assets/images/projects/ammi/ammi2.jpg',
|
|
||||||
'assets/images/projects/ammi/ammi3.jpg'
|
|
||||||
],
|
|
||||||
accentColor: '#9ece6a'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'vacaciones-rrhh',
|
|
||||||
code: 'PRJ-02',
|
|
||||||
title: 'Módulo Vacaciones RRHH',
|
|
||||||
subtitle: t.prj3Subtitle,
|
|
||||||
institution: t.prj3Institution,
|
|
||||||
description: t.prj3Desc,
|
|
||||||
impact: t.prj3Impact,
|
|
||||||
challenge: t.prj3Challenge,
|
|
||||||
solution: t.prj3Solution,
|
|
||||||
results: [t.prj3Result1, t.prj3Result2, t.prj3Result3],
|
|
||||||
tags: ['Angular', '.NET 9', 'Clean Arch', 'JWT & RBAC', 'Algoritmo FIFO', 'Reportes PDF'],
|
|
||||||
imageUrl: 'assets/images/projects/vacaciones-rrhh/rrhh1.jpg',
|
|
||||||
images: [
|
|
||||||
'assets/images/projects/vacaciones-rrhh/rrhh1.jpg',
|
|
||||||
'assets/images/projects/vacaciones-rrhh/rrhh2.jpg',
|
|
||||||
'assets/images/projects/vacaciones-rrhh/rrhh3.jpg'
|
|
||||||
],
|
|
||||||
accentColor: '#e0af68'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'titulacion',
|
|
||||||
code: 'PRJ-03',
|
|
||||||
title: 'Sistema de Titulación',
|
|
||||||
subtitle: t.prj4Subtitle,
|
|
||||||
institution: t.prj4Institution,
|
|
||||||
description: t.prj4Desc,
|
|
||||||
impact: t.prj4Impact,
|
|
||||||
challenge: t.prj4Challenge,
|
|
||||||
solution: t.prj4Solution,
|
|
||||||
results: [t.prj4Result1, t.prj4Result2, t.prj4Result3],
|
|
||||||
tags: ['Angular', '.NET 9', 'Clean Arch', 'JWT & RBAC', 'Gestión de Convocatorias', 'En Desarrollo'],
|
|
||||||
imageUrl: 'assets/images/projects/titulacion/tit1.jpg',
|
|
||||||
images: [
|
|
||||||
'assets/images/projects/titulacion/tit1.jpg',
|
|
||||||
'assets/images/projects/titulacion/tit2.jpg',
|
|
||||||
'assets/images/projects/titulacion/tit3.jpg'
|
|
||||||
],
|
|
||||||
accentColor: '#bb9af7',
|
|
||||||
inDevelopment: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'siplece',
|
|
||||||
code: 'PRJ-04',
|
|
||||||
title: 'SIPLECE',
|
|
||||||
subtitle: t.prj1Subtitle,
|
|
||||||
institution: 'IST Mayor Pedro Traversari',
|
|
||||||
description: t.prj1Desc,
|
|
||||||
impact: t.prj1Impact,
|
|
||||||
challenge: t.prj1Challenge,
|
|
||||||
solution: t.prj1Solution,
|
|
||||||
results: [t.prj1Result1, t.prj1Result2, t.prj1Result3],
|
|
||||||
tags: ['Angular', '.NET 8', 'Clean Arch', 'JWT & RBAC', 'SharePoint'],
|
|
||||||
accentColor: '#7dcfff',
|
|
||||||
images: [] // Sin imágenes, diseño de ficha de arquitectura
|
|
||||||
}
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Número de tarjetas visibles según tamaño de pantalla (1 en móvil, 2 en tablet, 3 en desktop)
|
|
||||||
protected readonly cardsPerPage = computed(() => this.screenCols());
|
|
||||||
|
|
||||||
// Índice máximo permitido para desplazamiento
|
|
||||||
protected readonly maxIndex = computed(() => {
|
|
||||||
const total = this.projects().length;
|
|
||||||
const perPage = this.cardsPerPage();
|
|
||||||
return Math.max(0, total - perPage);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Total de páginas / pasos de navegación
|
|
||||||
protected readonly totalPages = computed(() => this.maxIndex() + 1);
|
|
||||||
|
|
||||||
// Array de índices para los dots indicadores
|
|
||||||
protected readonly pagesArray = computed(() =>
|
|
||||||
Array.from({ length: this.totalPages() }, (_, i) => i)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tarjetas visibles en la pantalla en este momento
|
|
||||||
protected readonly visibleProjects = computed(() => {
|
|
||||||
const start = this.currentIndex();
|
|
||||||
const count = this.cardsPerPage();
|
|
||||||
return this.projects().slice(start, start + count);
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly canPrev = computed(() => this.currentIndex() > 0);
|
|
||||||
protected readonly canNext = computed(() => this.currentIndex() < this.maxIndex());
|
|
||||||
|
|
||||||
protected prev(): void {
|
|
||||||
if (this.canPrev()) {
|
|
||||||
this.currentIndex.update(i => i - 1);
|
|
||||||
} else {
|
|
||||||
this.currentIndex.set(this.maxIndex());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected next(): void {
|
|
||||||
if (this.canNext()) {
|
|
||||||
this.currentIndex.update(i => i + 1);
|
|
||||||
} else {
|
|
||||||
this.currentIndex.set(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected goToPage(index: number): void {
|
|
||||||
this.currentIndex.set(Math.max(0, Math.min(index, this.maxIndex())));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly selectedProjectId = signal<string | null>(null);
|
|
||||||
|
|
||||||
protected readonly selectedProject = computed(() => {
|
|
||||||
const selectedId = this.selectedProjectId();
|
|
||||||
if (!selectedId) return null;
|
|
||||||
return this.projects().find(project => project.id === selectedId) ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly imageErrors = signal<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
protected selectProject(projectId: string): void {
|
|
||||||
this.selectedProjectId.set(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected clearSelection(): void {
|
|
||||||
this.selectedProjectId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageError(imageKey: string): void {
|
|
||||||
this.imageErrors.update(curr => ({ ...curr, [imageKey]: true }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,383 +0,0 @@
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: clamp(1.25rem, 2.5vw, 2.25rem);
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-md);
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(14, 18, 24, 0.98), rgba(18, 22, 30, 0.94)),
|
|
||||||
#0c1015;
|
|
||||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding-bottom: var(--space-3);
|
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-kicker {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-dev-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
background: rgba(187, 154, 247, 0.12);
|
|
||||||
border: 1px solid rgba(187, 154, 247, 0.3);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.625rem;
|
|
||||||
color: #bb9af7;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button:hover {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
border-color: var(--zen-accent-kintsugi);
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.15fr 1fr;
|
|
||||||
gap: var(--space-6);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-visual {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-image-viewport {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 10;
|
|
||||||
max-height: 460px;
|
|
||||||
border-radius: var(--zen-radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
background: #070a0e;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-image-viewport img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
object-position: center;
|
|
||||||
background-color: #070a0e;
|
|
||||||
transition: opacity var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-thumbs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--space-2);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb-btn {
|
|
||||||
aspect-ratio: 16 / 10;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: var(--zen-radius-xs);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
background: #080b0f;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: border-color var(--transition-fast), transform var(--transition-fast), opacity var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb-btn.is-active {
|
|
||||||
opacity: 1;
|
|
||||||
border: 2px solid var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 0 12px rgba(224, 175, 104, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb-btn img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.03), transparent 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel de Arquitectura Técnica para proyectos sin capturas (SIPLECE) */
|
|
||||||
.technical-blueprint-panel {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 320px;
|
|
||||||
border-radius: var(--zen-radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at center, rgba(125, 207, 255, 0.06) 0%, transparent 70%),
|
|
||||||
#080b12;
|
|
||||||
padding: var(--space-5);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-circuit-bg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(125, 207, 255, 0.06) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(125, 207, 255, 0.06) 1px, transparent 1px);
|
|
||||||
background-size: 20px 20px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-panel-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-panel-icon {
|
|
||||||
font-size: 2.4rem;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-panel-code {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-panel-title {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-panel-subtitle {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-specs-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blueprint-specs-list .spec-item {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border-radius: var(--zen-radius-xs);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cuerpo de Detalle del Proyecto */
|
|
||||||
.project-detail-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-pill.subtle {
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-summary {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-block {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-sm);
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-block h4 {
|
|
||||||
margin: 0 0 0.4rem;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-block p,
|
|
||||||
.detail-block li {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-block ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.15rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-tags span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.project-detail-panel {
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-detail-header {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
@if (project) {
|
|
||||||
<section class="project-detail-panel" [style.--prj-accent]="project.accentColor" aria-live="polite">
|
|
||||||
<div class="project-detail-header">
|
|
||||||
<div>
|
|
||||||
<span class="detail-kicker">
|
|
||||||
{{ project.code }}
|
|
||||||
@if (project.inDevelopment) {
|
|
||||||
<span class="detail-dev-tag">● {{ i18n.t().fpInDevelopment }}</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<h3>{{ project.title }}</h3>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="close-button" (click)="onClose()" [attr.aria-label]="i18n.t().projectDetailCloseAria">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-detail-layout">
|
|
||||||
<div class="project-detail-visual">
|
|
||||||
@if (project.images && project.images.length > 0) {
|
|
||||||
<div class="main-image-viewport">
|
|
||||||
@if (getCurrentImage(); as currentImg) {
|
|
||||||
@if (!imageErrors()[currentImg]) {
|
|
||||||
<img
|
|
||||||
[src]="currentImg"
|
|
||||||
[alt]="project.title + ' vista ' + (activeImageIndex() + 1)"
|
|
||||||
(error)="onImageError(currentImg)"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<div class="detail-placeholder">
|
|
||||||
<span class="placeholder-icon">◧</span>
|
|
||||||
<span class="placeholder-code">{{ project.code }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="detail-placeholder">
|
|
||||||
<span class="placeholder-icon">◧</span>
|
|
||||||
<span class="placeholder-code">{{ project.code }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (project.images.length > 1) {
|
|
||||||
<div class="gallery-thumbs" role="tablist" aria-label="Miniaturas de capturas">
|
|
||||||
@for (img of project.images; track img; let i = $index) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="thumb-btn"
|
|
||||||
[class.is-active]="activeImageIndex() === i"
|
|
||||||
[attr.aria-selected]="activeImageIndex() === i"
|
|
||||||
(click)="selectImage(i)"
|
|
||||||
>
|
|
||||||
@if (!imageErrors()[img]) {
|
|
||||||
<img [src]="img" [alt]="project.title + ' miniatura ' + (i + 1)" (error)="onImageError(img)" loading="lazy" />
|
|
||||||
} @else {
|
|
||||||
<span class="thumb-label">{{ i + 1 }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<!-- Ficha de Arquitectura Técnica para proyectos sin imágenes (SIPLECE) -->
|
|
||||||
<div class="technical-blueprint-panel">
|
|
||||||
<div class="blueprint-circuit-bg" aria-hidden="true"></div>
|
|
||||||
<div class="blueprint-panel-content">
|
|
||||||
<span class="blueprint-panel-icon">◧</span>
|
|
||||||
<span class="blueprint-panel-code">{{ project.code }}</span>
|
|
||||||
<h4 class="blueprint-panel-title">{{ project.title }}</h4>
|
|
||||||
<p class="blueprint-panel-subtitle">{{ i18n.t().fpNoImagesTechnical }}</p>
|
|
||||||
<div class="blueprint-specs-list">
|
|
||||||
<span class="spec-item">🏛 {{ project.institution }}</span>
|
|
||||||
<span class="spec-item">🔒 Autenticación JWT & RBAC</span>
|
|
||||||
<span class="spec-item">⚡ Clean Architecture (.NET + Angular)</span>
|
|
||||||
<span class="spec-item">📊 Trazabilidad & Auditoría CACES</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-detail-body">
|
|
||||||
<div class="project-detail-meta">
|
|
||||||
<span class="meta-pill">{{ project.institution }}</span>
|
|
||||||
<span class="meta-pill subtle">{{ project.subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="project-detail-summary">{{ project.description }}</p>
|
|
||||||
|
|
||||||
<div class="detail-block">
|
|
||||||
<h4>{{ i18n.t().projectDetailChallenge }}</h4>
|
|
||||||
<p>{{ project.challenge }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-block">
|
|
||||||
<h4>{{ i18n.t().projectDetailSolution }}</h4>
|
|
||||||
<p>{{ project.solution }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-block results-block">
|
|
||||||
<h4>{{ i18n.t().projectDetailResults }}</h4>
|
|
||||||
<ul>
|
|
||||||
@for (result of project.results; track result) {
|
|
||||||
<li>{{ result }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-tags">
|
|
||||||
@for (tag of project.tags; track tag) {
|
|
||||||
<span>{{ tag }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject, signal } from '@angular/core';
|
|
||||||
import { I18nService } from '../../../../application/services/i18n.service';
|
|
||||||
import { ProjectCardData } from '../featured-projects/featured-projects.component';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-project-detail',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './project-detail.component.html',
|
|
||||||
styleUrl: './project-detail.component.css',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class ProjectDetailComponent {
|
|
||||||
protected readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
@Input() project: ProjectCardData | null = null;
|
|
||||||
|
|
||||||
@Output() close = new EventEmitter<void>();
|
|
||||||
|
|
||||||
protected readonly activeImageIndex = signal<number>(0);
|
|
||||||
protected readonly imageErrors = signal<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
protected onClose(): void {
|
|
||||||
this.close.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected selectImage(index: number): void {
|
|
||||||
this.activeImageIndex.set(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onImageError(imagePath: string): void {
|
|
||||||
this.imageErrors.update(curr => ({ ...curr, [imagePath]: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getCurrentImage(): string | null {
|
|
||||||
if (!this.project) return null;
|
|
||||||
const images = this.project.images;
|
|
||||||
if (images && images.length > this.activeImageIndex()) {
|
|
||||||
return images[this.activeImageIndex()];
|
|
||||||
}
|
|
||||||
return this.project.imageUrl ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,343 +24,75 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.lateral-panel {
|
.lateral-panel {
|
||||||
position: absolute; z-index: 10; width: min(100%, 25rem); opacity: 0; visibility: hidden; pointer-events: none;
|
position: absolute; z-index: 10; width: 100%; opacity: 0; visibility: hidden; pointer-events: none;
|
||||||
transition: opacity 0.4s ease, transform 0.4s ease, visibility 0.4s;
|
transition: opacity 0.4s ease, transform 0.4s ease, visibility 0.4s;
|
||||||
}
|
}
|
||||||
.side-left { left: clamp(0.75rem, 1.8vw, 2.25rem); transform: translateY(calc(-50% + 18px)); }
|
.side-left { left: clamp(1.5rem, 4vw, 4rem); transform: translateY(calc(-50% + 16px)); }
|
||||||
.side-right { right: clamp(0.75rem, 1.8vw, 2.25rem); transform: translateY(calc(-50% + 18px)); }
|
.side-right { right: clamp(1.5rem, 4vw, 4rem); transform: translateY(calc(-50% + 16px)); }
|
||||||
.lateral-panel.active { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(-50%); }
|
.lateral-panel.active { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(-50%); }
|
||||||
|
|
||||||
.pos-f1-left {
|
.pos-f1-left { top: 45%; max-width: 440px; }
|
||||||
top: 46%;
|
.pos-f1-right { top: 58%; max-width: 275px; }
|
||||||
left: clamp(2rem, 4vw, 4rem);
|
.pos-f2-left { top: 54%; max-width: 370px; }
|
||||||
max-width: 340px;
|
.pos-f2-right { top: 40%; max-width: 330px; }
|
||||||
}
|
.pos-f3-left { top: 42%; max-width: 300px; }
|
||||||
.pos-f1-right { top: 58%; max-width: 220px; }
|
.pos-f3-right { top: 52%; max-width: 380px; }
|
||||||
.pos-f2-left { top: 50%; max-width: 300px; }
|
|
||||||
.pos-f2-right { top: 42%; max-width: 240px; }
|
|
||||||
.pos-f3-left { top: 46%; max-width: 280px; }
|
|
||||||
.pos-f3-right { top: 54%; max-width: 250px; }
|
|
||||||
|
|
||||||
/* Contenedores Flotantes (Sin Cards) */
|
/* Tarjeta Insignia Principal de Bryan */
|
||||||
.hero-floating-lead, .hero-floating-item {
|
.hero-lead-card, .panel-card {
|
||||||
background: transparent;
|
border: 1px solid var(--zen-border-subtle); border-radius: 2px;
|
||||||
border: none;
|
backdrop-filter: blur(12px);
|
||||||
padding: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
.hero-lead-card {
|
||||||
.lead-header {
|
background: rgba(11, 14, 20, 0.9);
|
||||||
display: flex;
|
border-color: rgba(197, 155, 39, 0.45);
|
||||||
flex-direction: column;
|
border-left: 4px solid var(--zen-accent-kintsugi);
|
||||||
align-items: flex-start;
|
padding: 24px;
|
||||||
gap: 16px;
|
box-shadow: 0 0 35px rgba(197, 155, 39, 0.18), 0 20px 50px rgba(0, 0, 0, 0.9);
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.lead-status {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
color: var(--zen-accent-matcha);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
.lead-status::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--zen-accent-matcha);
|
|
||||||
box-shadow: 0 0 8px var(--zen-accent-matcha);
|
|
||||||
}
|
|
||||||
.lead-tag {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
color: var(--zen-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
}
|
||||||
|
.lead-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.lead-status { font-family: var(--font-sans); font-size: 0.75rem; color: var(--zen-accent-matcha); font-weight: 700; }
|
||||||
|
.lead-tag { font-family: var(--font-sans); font-size: 0.75rem; color: var(--zen-text-muted); font-weight: 600; }
|
||||||
.lead-name {
|
.lead-name {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: clamp(2.1rem, 3vw, 3rem);
|
font-size: clamp(2rem, 3vw, 2.5rem); font-weight: 800; color: var(--zen-text-primary);
|
||||||
font-weight: 800;
|
line-height: 1.1; letter-spacing: -0.02em; margin-bottom: 2px;
|
||||||
color: var(--zen-text-primary);
|
|
||||||
line-height: 1.16;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.95), 0 4px 24px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
}
|
||||||
.lead-role, .panel-eyebrow {
|
.lead-role, .panel-eyebrow {
|
||||||
display: block;
|
display: block; font-family: var(--font-sans); font-size: 0.8125rem; font-weight: 700;
|
||||||
font-family: var(--font-sans);
|
letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
font-size: 0.66rem;
|
color: var(--zen-accent-kintsugi); margin-bottom: 8px;
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
margin-bottom: 18px;
|
|
||||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.85);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
.lead-desc {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
line-height: 1.75;
|
|
||||||
margin: 0 0 24px 0;
|
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.9);
|
|
||||||
max-width: 25ch;
|
|
||||||
}
|
|
||||||
.lead-badges, .panel-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.lead-footer {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
padding-top: 16px;
|
|
||||||
margin-top: 28px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
.lead-metric {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--zen-text-muted);
|
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
.lead-social-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.social-link {
|
|
||||||
display: inline-grid;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
.social-link:hover,
|
|
||||||
.social-link:focus-visible {
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
border-color: var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
.social-link svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
.social-link:last-child svg {
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 1.5;
|
|
||||||
}
|
|
||||||
.social-link-disabled {
|
|
||||||
color: var(--zen-text-muted);
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
.lead-desc { font-family: var(--font-sans); font-size: 0.9375rem; color: var(--zen-text-secondary); line-height: 1.6; margin-bottom: 16px; }
|
||||||
|
.lead-badges, .panel-tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.lead-footer { border-top: 1px solid var(--zen-border-subtle); padding-top: 10px; margin-top: 14px; }
|
||||||
|
.lead-metric { font-family: var(--font-sans); font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em; color: var(--zen-text-muted); }
|
||||||
|
|
||||||
.panel-title {
|
/* Tarjetas Secundarias */
|
||||||
font-family: var(--font-sans);
|
.panel-card {
|
||||||
font-size: clamp(1.1rem, 1.5vw, 1.35rem);
|
background: rgba(10, 12, 16, 0.82);
|
||||||
font-weight: 800;
|
border-left: 3px solid var(--zen-accent-kintsugi);
|
||||||
line-height: 1.18;
|
padding: 18px;
|
||||||
letter-spacing: -0.02em;
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.85);
|
||||||
color: var(--zen-text-primary);
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.95), 0 4px 24px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
.panel-desc {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
line-height: 1.38;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
}
|
||||||
|
.compact-card { padding: 14px; }
|
||||||
|
.panel-title { font-family: var(--font-sans); font-size: 1.3rem; font-weight: 800; line-height: 1.25; color: var(--zen-text-primary); margin-bottom: 6px; }
|
||||||
|
.panel-desc { font-family: var(--font-sans); font-size: 0.875rem; color: var(--zen-text-secondary); line-height: 1.55; margin-bottom: 12px; }
|
||||||
|
|
||||||
/* Chips de Etiquetas Flotantes */
|
/* Chips de Etiquetas */
|
||||||
.zen-chip {
|
.zen-chip {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans); font-size: 0.75rem; font-weight: 600; padding: 3px 9px; border-radius: 2px;
|
||||||
font-size: 0.64rem;
|
background: rgba(255, 255, 255, 0.04); border: 1px solid var(--zen-border-subtle); color: var(--zen-text-primary);
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: rgba(16, 20, 26, 0.7);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-gold {
|
|
||||||
border-color: rgba(197, 155, 39, 0.45);
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
background: rgba(197, 155, 39, 0.12);
|
|
||||||
}
|
|
||||||
.chip-matcha {
|
|
||||||
border-color: rgba(78, 155, 111, 0.45);
|
|
||||||
color: var(--zen-accent-matcha);
|
|
||||||
background: rgba(78, 155, 111, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-only {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
.chip-gold { border-color: rgba(197, 155, 39, 0.45); color: var(--zen-accent-kintsugi); background: rgba(197, 155, 39, 0.08); }
|
||||||
|
.chip-matcha { border-color: rgba(78, 155, 111, 0.45); color: var(--zen-accent-matcha); background: rgba(78, 155, 111, 0.08); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.desktop-only {
|
.lateral-panel {
|
||||||
display: none !important;
|
max-width: min(90%, 380px) !important; left: 50% !important; right: auto !important;
|
||||||
}
|
transform: translateX(-50%) translateY(16px);
|
||||||
|
|
||||||
.mobile-only {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-2) 0 var(--space-8);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tarjeta Principal de Perfil Móvil */
|
|
||||||
.mobile-lead-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-5);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(18, 22, 30, 0.96), rgba(13, 16, 22, 0.92)),
|
|
||||||
#0d1117;
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-md);
|
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-name {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: clamp(2rem, 7vw, 2.6rem);
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
line-height: 1.12;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-role {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-desc {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
line-height: 1.65;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-badges {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: var(--space-3);
|
|
||||||
border-top: 1px solid var(--zen-border-subtle);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-lead-metric {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--zen-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-social-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cuadrícula de Pilares Técnicos Móvil */
|
|
||||||
.mobile-pillars-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
|
||||||
.mobile-pillars-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-pillar-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: var(--space-4);
|
|
||||||
background: rgba(18, 22, 28, 0.7);
|
|
||||||
border: 1px solid var(--zen-border-subtle);
|
|
||||||
border-radius: var(--zen-radius-sm);
|
|
||||||
box-sizing: border-box;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-pillar-card .panel-title {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-pillar-card .panel-desc {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-pillar-card .panel-tags {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
.side-left { top: 12% !important; }
|
||||||
|
.side-right { top: auto !important; bottom: 8% !important; }
|
||||||
|
.lateral-panel.active { transform: translateX(-50%) translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<!-- Vista Desktop: Animación 3D Laptop Lenovo ThinkPad (min-width: 901px) -->
|
<section #scrollContainer class="hero-scroll-wrapper" [attr.aria-label]="i18n.t().heroAriaExperience">
|
||||||
<section #scrollContainer class="hero-scroll-wrapper desktop-only" [attr.aria-label]="i18n.t().heroAriaExperience">
|
|
||||||
<div class="hero-sticky-stage">
|
<div class="hero-sticky-stage">
|
||||||
<!-- Lienzo Canvas en alta resolución que ocupa toda la pantalla de fondo -->
|
<!-- Lienzo Canvas en alta resolución que ocupa toda la pantalla de fondo -->
|
||||||
<canvas #canvas class="thinkpad-canvas" aria-hidden="true"></canvas>
|
<canvas #canvas class="thinkpad-canvas" aria-hidden="true"></canvas>
|
||||||
|
|
@ -12,9 +11,9 @@
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- FASE 1: Información Insignia Principal de Bryan (Izquierda) y Filosofía Asimétrica (Derecha) -->
|
<!-- FASE 1: Tarjeta Insignia Principal de Bryan (Izquierda) y Filosofía Asimétrica (Derecha) -->
|
||||||
<aside class="lateral-panel side-left pos-f1-left" [class.active]="activePhase() === 1" [attr.aria-label]="i18n.t().heroAriaLead">
|
<aside class="lateral-panel side-left pos-f1-left" [class.active]="activePhase() === 1" [attr.aria-label]="i18n.t().heroAriaLead">
|
||||||
<div class="hero-floating-lead">
|
<div class="hero-lead-card">
|
||||||
<div class="lead-header">
|
<div class="lead-header">
|
||||||
<span class="lead-status">{{ i18n.t().p1LeadStatus }}</span>
|
<span class="lead-status">{{ i18n.t().p1LeadStatus }}</span>
|
||||||
<span class="lead-tag">{{ i18n.t().p1LeadTag }}</span>
|
<span class="lead-tag">{{ i18n.t().p1LeadTag }}</span>
|
||||||
|
|
@ -29,49 +28,12 @@
|
||||||
</div>
|
</div>
|
||||||
<footer class="lead-footer">
|
<footer class="lead-footer">
|
||||||
<span class="lead-metric">{{ i18n.t().p1LeadMetric }}</span>
|
<span class="lead-metric">{{ i18n.t().p1LeadMetric }}</span>
|
||||||
<nav class="lead-social-links" aria-label="Redes sociales">
|
|
||||||
<a
|
|
||||||
class="social-link"
|
|
||||||
href="https://github.com/bryancito1090"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.61-3.37-1.18-3.37-1.18-.45-1.15-1.11-1.46-1.11-1.46-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.58 9.58 0 0 1 12 7.99c.85 0 1.7.11 2.49.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.6c0 .26.18.57.69.47A10 10 0 0 0 12 2Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="social-link"
|
|
||||||
href="https://www.linkedin.com/in/bryan-bano"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="LinkedIn"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M5.2 3.5a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1 0-4.4ZM3.4 9.2h3.6V21H3.4V9.2Zm5.8 0h3.4v1.61h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45V21h-3.6v-5.84c0-1.39-.03-3.18-1.94-3.18-1.94 0-2.24 1.52-2.24 3.08V21H9.2V9.2Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="social-link"
|
|
||||||
href="https://www.instagram.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect x="3.5" y="3.5" width="17" height="17" rx="4"/>
|
|
||||||
<circle cx="12" cy="12" r="4"/>
|
|
||||||
<circle cx="17.5" cy="6.5" r="1"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="lateral-panel side-right pos-f1-right" [class.active]="activePhase() === 1" [attr.aria-label]="i18n.t().heroAriaPhilosophy">
|
<aside class="lateral-panel side-right pos-f1-right" [class.active]="activePhase() === 1" [attr.aria-label]="i18n.t().heroAriaPhilosophy">
|
||||||
<div class="hero-floating-item">
|
<div class="panel-card compact-card">
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p1RightTag }}</span>
|
<span class="panel-eyebrow">{{ i18n.t().p1RightTag }}</span>
|
||||||
<h2 class="panel-title">{{ i18n.t().p1RightTitle }}</h2>
|
<h2 class="panel-title">{{ i18n.t().p1RightTitle }}</h2>
|
||||||
<p class="panel-desc">{{ i18n.t().p1RightDesc }}</p>
|
<p class="panel-desc">{{ i18n.t().p1RightDesc }}</p>
|
||||||
|
|
@ -83,7 +45,7 @@
|
||||||
|
|
||||||
<!-- FASE 2: Backend Enterprise y Frontend Reactivo (Asimétrico) -->
|
<!-- FASE 2: Backend Enterprise y Frontend Reactivo (Asimétrico) -->
|
||||||
<aside class="lateral-panel side-left pos-f2-left" [class.active]="activePhase() === 2" [attr.aria-label]="i18n.t().heroAriaBackend">
|
<aside class="lateral-panel side-left pos-f2-left" [class.active]="activePhase() === 2" [attr.aria-label]="i18n.t().heroAriaBackend">
|
||||||
<div class="hero-floating-item">
|
<div class="panel-card">
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p2LeftTag }}</span>
|
<span class="panel-eyebrow">{{ i18n.t().p2LeftTag }}</span>
|
||||||
<h2 class="panel-title">{{ i18n.t().p2LeftTitle }}</h2>
|
<h2 class="panel-title">{{ i18n.t().p2LeftTitle }}</h2>
|
||||||
<p class="panel-desc">{{ i18n.t().p2LeftDesc }}</p>
|
<p class="panel-desc">{{ i18n.t().p2LeftDesc }}</p>
|
||||||
|
|
@ -95,7 +57,7 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="lateral-panel side-right pos-f2-right" [class.active]="activePhase() === 2" [attr.aria-label]="i18n.t().heroAriaFrontend">
|
<aside class="lateral-panel side-right pos-f2-right" [class.active]="activePhase() === 2" [attr.aria-label]="i18n.t().heroAriaFrontend">
|
||||||
<div class="hero-floating-item">
|
<div class="panel-card">
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p2RightTag }}</span>
|
<span class="panel-eyebrow">{{ i18n.t().p2RightTag }}</span>
|
||||||
<h2 class="panel-title">{{ i18n.t().p2RightTitle }}</h2>
|
<h2 class="panel-title">{{ i18n.t().p2RightTitle }}</h2>
|
||||||
<p class="panel-desc">{{ i18n.t().p2RightDesc }}</p>
|
<p class="panel-desc">{{ i18n.t().p2RightDesc }}</p>
|
||||||
|
|
@ -108,7 +70,7 @@
|
||||||
|
|
||||||
<!-- FASE 3: Estándares de Código y Laboratorio en Vivo (Asimétrico) -->
|
<!-- FASE 3: Estándares de Código y Laboratorio en Vivo (Asimétrico) -->
|
||||||
<aside class="lateral-panel side-left pos-f3-left" [class.active]="activePhase() === 3" [attr.aria-label]="i18n.t().heroAriaStandards">
|
<aside class="lateral-panel side-left pos-f3-left" [class.active]="activePhase() === 3" [attr.aria-label]="i18n.t().heroAriaStandards">
|
||||||
<div class="hero-floating-item">
|
<div class="panel-card compact-card">
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p3LeftTag }}</span>
|
<span class="panel-eyebrow">{{ i18n.t().p3LeftTag }}</span>
|
||||||
<h2 class="panel-title">{{ i18n.t().p3LeftTitle }}</h2>
|
<h2 class="panel-title">{{ i18n.t().p3LeftTitle }}</h2>
|
||||||
<p class="panel-desc">{{ i18n.t().p3LeftDesc }}</p>
|
<p class="panel-desc">{{ i18n.t().p3LeftDesc }}</p>
|
||||||
|
|
@ -120,7 +82,7 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="lateral-panel side-right pos-f3-right" [class.active]="activePhase() === 3" [attr.aria-label]="i18n.t().heroAriaInteractive">
|
<aside class="lateral-panel side-right pos-f3-right" [class.active]="activePhase() === 3" [attr.aria-label]="i18n.t().heroAriaInteractive">
|
||||||
<div class="hero-floating-item">
|
<div class="panel-card">
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p3RightTag }}</span>
|
<span class="panel-eyebrow">{{ i18n.t().p3RightTag }}</span>
|
||||||
<h2 class="panel-title">{{ i18n.t().p3RightTitle }}</h2>
|
<h2 class="panel-title">{{ i18n.t().p3RightTitle }}</h2>
|
||||||
<p class="panel-desc">{{ i18n.t().p3RightDesc }}</p>
|
<p class="panel-desc">{{ i18n.t().p3RightDesc }}</p>
|
||||||
|
|
@ -132,122 +94,3 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Vista Móvil: Información estructurada y limpia sin animación pesada (max-width: 900px) -->
|
|
||||||
<section class="hero-mobile-container mobile-only" [attr.aria-label]="i18n.t().heroAriaExperience">
|
|
||||||
<!-- Tarjeta Principal de Perfil / Lead -->
|
|
||||||
<div class="mobile-lead-card">
|
|
||||||
<div class="mobile-lead-header">
|
|
||||||
<span class="lead-status">{{ i18n.t().p1LeadStatus }}</span>
|
|
||||||
<span class="lead-tag">{{ i18n.t().p1LeadTag }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="mobile-lead-name">{{ i18n.t().p1LeadName }}</h1>
|
|
||||||
<span class="mobile-lead-role">{{ i18n.t().p1LeadRole }}</span>
|
|
||||||
<p class="mobile-lead-desc">{{ i18n.t().p1LeadDesc }}</p>
|
|
||||||
|
|
||||||
<div class="mobile-lead-badges">
|
|
||||||
<span class="zen-chip chip-gold">{{ i18n.t().p1LeadBadge1 }}</span>
|
|
||||||
<span class="zen-chip chip-matcha">{{ i18n.t().p1LeadBadge2 }}</span>
|
|
||||||
<span class="zen-chip">{{ i18n.t().p1LeadBadge3 }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-lead-footer">
|
|
||||||
<span class="mobile-lead-metric">{{ i18n.t().p1LeadMetric }}</span>
|
|
||||||
<nav class="mobile-social-links" aria-label="Redes sociales">
|
|
||||||
<a
|
|
||||||
class="social-link"
|
|
||||||
href="https://github.com/bryancito1090"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.61-3.37-1.18-3.37-1.18-.45-1.15-1.11-1.46-1.11-1.46-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.58 9.58 0 0 1 12 7.99c.85 0 1.7.11 2.49.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.6c0 .26.18.57.69.47A10 10 0 0 0 12 2Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="social-link"
|
|
||||||
href="https://www.linkedin.com/in/bryan-bano"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="LinkedIn"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M5.2 3.5a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1 0-4.4ZM3.4 9.2h3.6V21H3.4V9.2Zm5.8 0h3.4v1.61h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45V21h-3.6v-5.84c0-1.39-.03-3.18-1.94-3.18-1.94 0-2.24 1.52-2.24 3.08V21H9.2V9.2Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="social-link"
|
|
||||||
href="https://www.instagram.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect x="3.5" y="3.5" width="17" height="17" rx="4"/>
|
|
||||||
<circle cx="12" cy="12" r="4"/>
|
|
||||||
<circle cx="17.5" cy="6.5" r="1"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cuadrícula de Pilares Técnicos y Filosofía -->
|
|
||||||
<div class="mobile-pillars-grid">
|
|
||||||
<!-- Pilar 1: Backend Enterprise -->
|
|
||||||
<div class="mobile-pillar-card">
|
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p2LeftTag }}</span>
|
|
||||||
<h3 class="panel-title">{{ i18n.t().p2LeftTitle }}</h3>
|
|
||||||
<p class="panel-desc">{{ i18n.t().p2LeftDesc }}</p>
|
|
||||||
<div class="panel-tags">
|
|
||||||
<span class="zen-chip chip-gold">{{ i18n.t().p2LeftBadge1 }}</span>
|
|
||||||
<span class="zen-chip">{{ i18n.t().p2LeftBadge2 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pilar 2: Frontend Reactivo -->
|
|
||||||
<div class="mobile-pillar-card">
|
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p2RightTag }}</span>
|
|
||||||
<h3 class="panel-title">{{ i18n.t().p2RightTitle }}</h3>
|
|
||||||
<p class="panel-desc">{{ i18n.t().p2RightDesc }}</p>
|
|
||||||
<div class="panel-tags">
|
|
||||||
<span class="zen-chip chip-matcha">{{ i18n.t().p2RightBadge1 }}</span>
|
|
||||||
<span class="zen-chip">{{ i18n.t().p2RightBadge2 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pilar 3: Arquitectura & Filosofía -->
|
|
||||||
<div class="mobile-pillar-card">
|
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p1RightTag }}</span>
|
|
||||||
<h3 class="panel-title">{{ i18n.t().p1RightTitle }}</h3>
|
|
||||||
<p class="panel-desc">{{ i18n.t().p1RightDesc }}</p>
|
|
||||||
<div class="panel-tags">
|
|
||||||
<span class="zen-chip">{{ i18n.t().p1RightBadge }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pilar 4: Estándares de Código -->
|
|
||||||
<div class="mobile-pillar-card">
|
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p3LeftTag }}</span>
|
|
||||||
<h3 class="panel-title">{{ i18n.t().p3LeftTitle }}</h3>
|
|
||||||
<p class="panel-desc">{{ i18n.t().p3LeftDesc }}</p>
|
|
||||||
<div class="panel-tags">
|
|
||||||
<span class="zen-chip">{{ i18n.t().p3LeftBadge1 }}</span>
|
|
||||||
<span class="zen-chip">{{ i18n.t().p3LeftBadge2 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pilar 5: Laboratorio Interactivo -->
|
|
||||||
<div class="mobile-pillar-card">
|
|
||||||
<span class="panel-eyebrow">{{ i18n.t().p3RightTag }}</span>
|
|
||||||
<h3 class="panel-title">{{ i18n.t().p3RightTitle }}</h3>
|
|
||||||
<p class="panel-desc">{{ i18n.t().p3RightDesc }}</p>
|
|
||||||
<div class="panel-tags">
|
|
||||||
<span class="zen-chip chip-gold">{{ i18n.t().p3RightBadge1 }}</span>
|
|
||||||
<span class="zen-chip chip-matcha">{{ i18n.t().p3RightBadge2 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,12 @@ import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
signal,
|
signal,
|
||||||
inject,
|
inject,
|
||||||
NgZone,
|
NgZone
|
||||||
input
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
const TOTAL_FRAMES = 240;
|
const TOTAL_FRAMES = 240;
|
||||||
const LERP_FACTOR = 0.09;
|
const LERP_FACTOR = 0.09;
|
||||||
const SHARED_IMAGE_CACHE: HTMLImageElement[] = new Array(TOTAL_FRAMES + 1);
|
|
||||||
let isPreloadDone = false;
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero-3d',
|
selector: 'app-hero-3d',
|
||||||
|
|
@ -26,19 +23,19 @@ let isPreloadDone = false;
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
readonly initialPosition = input<'top' | 'bottom'>('top');
|
|
||||||
|
|
||||||
@ViewChild('scrollContainer') private containerRef!: ElementRef<HTMLDivElement>;
|
@ViewChild('scrollContainer') private containerRef!: ElementRef<HTMLDivElement>;
|
||||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
|
|
||||||
|
// Señales reactivas: Fase activa de tarjetas laterales y estado de carga
|
||||||
protected readonly activePhase = signal<number>(1);
|
protected readonly activePhase = signal<number>(1);
|
||||||
protected readonly loadProgress = signal<number>(0);
|
protected readonly loadProgress = signal<number>(0);
|
||||||
protected readonly isLoaded = signal<boolean>(false);
|
protected readonly isLoaded = signal<boolean>(false);
|
||||||
|
|
||||||
private ctx: CanvasRenderingContext2D | null = null;
|
private ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
private readonly images: HTMLImageElement[] = new Array(TOTAL_FRAMES + 1);
|
||||||
private targetFrame = 1;
|
private targetFrame = 1;
|
||||||
private currentInterpolatedFrame = 1;
|
private currentInterpolatedFrame = 1;
|
||||||
private lastRenderedFrame = 0;
|
private lastRenderedFrame = 0;
|
||||||
|
|
@ -50,32 +47,17 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
private mediaQueryList?: MediaQueryList;
|
private mediaQueryList?: MediaQueryList;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const isBottom = this.initialPosition() === 'bottom';
|
|
||||||
this.targetFrame = isBottom ? TOTAL_FRAMES : 1;
|
|
||||||
this.currentInterpolatedFrame = isBottom ? TOTAL_FRAMES : 1;
|
|
||||||
this.lastRenderedFrame = isBottom ? TOTAL_FRAMES : 1;
|
|
||||||
this.activePhase.set(isBottom ? 3 : 1);
|
|
||||||
|
|
||||||
this.checkReducedMotion();
|
this.checkReducedMotion();
|
||||||
if (typeof window !== 'undefined' && window.innerWidth <= 900) {
|
this.preloadImages();
|
||||||
this.isLoaded.set(true);
|
|
||||||
} else {
|
|
||||||
this.preloadImages();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
if (!this.canvasRef || !this.containerRef) {
|
|
||||||
this.isLoaded.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef.nativeElement;
|
||||||
this.ctx = canvas.getContext('2d', { alpha: false });
|
this.ctx = canvas.getContext('2d', { alpha: false });
|
||||||
|
|
||||||
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
||||||
this.isVisible = entry.isIntersecting;
|
this.isVisible = entry.isIntersecting;
|
||||||
if (this.isVisible && !this.isReducedMotion && typeof window !== 'undefined' && window.innerWidth > 900) {
|
if (this.isVisible && !this.isReducedMotion) {
|
||||||
this.startAnimationLoop();
|
this.startAnimationLoop();
|
||||||
} else if (this.animationFrameId !== null) {
|
} else if (this.animationFrameId !== null) {
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
|
@ -90,43 +72,8 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
window.addEventListener('resize', this.onResize, { passive: true });
|
window.addEventListener('resize', this.onResize, { passive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.innerWidth > 900) {
|
this.onResize();
|
||||||
const container = this.containerRef.nativeElement;
|
this.onScroll();
|
||||||
const isBottom = this.initialPosition() === 'bottom';
|
|
||||||
|
|
||||||
if (isBottom) {
|
|
||||||
const scrollableDistance = container.offsetHeight - window.innerHeight;
|
|
||||||
window.scrollTo({ top: scrollableDistance > 0 ? scrollableDistance : 3000, behavior: 'instant' });
|
|
||||||
this.targetFrame = TOTAL_FRAMES;
|
|
||||||
this.currentInterpolatedFrame = TOTAL_FRAMES;
|
|
||||||
this.lastRenderedFrame = TOTAL_FRAMES;
|
|
||||||
this.activePhase.set(3);
|
|
||||||
} else {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
this.targetFrame = 1;
|
|
||||||
this.currentInterpolatedFrame = 1;
|
|
||||||
this.lastRenderedFrame = 1;
|
|
||||||
this.activePhase.set(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onResize();
|
|
||||||
this.renderFrame(Math.round(this.currentInterpolatedFrame));
|
|
||||||
|
|
||||||
// Reasegurar la posición al desbloquear el scroll
|
|
||||||
if (isBottom) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const maxDist = container.offsetHeight - window.innerHeight;
|
|
||||||
if (maxDist > 0) {
|
|
||||||
window.scrollTo({ top: maxDist, behavior: 'instant' });
|
|
||||||
}
|
|
||||||
this.targetFrame = TOTAL_FRAMES;
|
|
||||||
this.currentInterpolatedFrame = TOTAL_FRAMES;
|
|
||||||
this.lastRenderedFrame = TOTAL_FRAMES;
|
|
||||||
this.activePhase.set(3);
|
|
||||||
this.renderFrame(TOTAL_FRAMES);
|
|
||||||
}, 780);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkReducedMotion(): void {
|
private checkReducedMotion(): void {
|
||||||
|
|
@ -141,54 +88,41 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.isLoopRunning = false;
|
this.isLoopRunning = false;
|
||||||
}
|
}
|
||||||
this.renderFrame(1);
|
this.renderFrame(1);
|
||||||
} else if (typeof window !== 'undefined' && window.innerWidth > 900) {
|
} else {
|
||||||
this.startAnimationLoop();
|
this.startAnimationLoop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private preloadImages(): void {
|
private preloadImages(): void {
|
||||||
if (isPreloadDone) {
|
|
||||||
this.loadProgress.set(100);
|
|
||||||
this.isLoaded.set(true);
|
|
||||||
this.renderFrame(this.targetFrame);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
const startFrame = this.initialPosition() === 'bottom' ? TOTAL_FRAMES : 1;
|
|
||||||
|
|
||||||
const loadSingle = (index: number, cb?: () => void) => {
|
const firstImg = new Image();
|
||||||
if (SHARED_IMAGE_CACHE[index]?.complete) {
|
firstImg.src = this.getFramePath(1);
|
||||||
loaded++;
|
firstImg.onload = () => {
|
||||||
cb?.();
|
this.images[1] = firstImg;
|
||||||
return;
|
loaded++;
|
||||||
}
|
this.renderFrame(1);
|
||||||
const img = new Image();
|
|
||||||
img.src = this.getFramePath(index);
|
for (let i = 2; i <= TOTAL_FRAMES; i++) {
|
||||||
img.onload = () => {
|
const img = new Image();
|
||||||
SHARED_IMAGE_CACHE[index] = img;
|
img.src = this.getFramePath(i);
|
||||||
loaded++;
|
img.onload = () => {
|
||||||
cb?.();
|
this.images[i] = img;
|
||||||
if (loaded % 12 === 0 || loaded === TOTAL_FRAMES) {
|
loaded++;
|
||||||
this.loadProgress.set(Math.round((loaded / TOTAL_FRAMES) * 100));
|
if (loaded % 12 === 0 || loaded === TOTAL_FRAMES) {
|
||||||
if (loaded === TOTAL_FRAMES) {
|
const pct = Math.round((loaded / TOTAL_FRAMES) * 100);
|
||||||
isPreloadDone = true;
|
this.loadProgress.set(pct);
|
||||||
this.isLoaded.set(true);
|
if (loaded === TOTAL_FRAMES) {
|
||||||
|
this.isLoaded.set(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
};
|
img.onerror = () => {
|
||||||
img.onerror = () => {
|
loaded++;
|
||||||
loaded++;
|
};
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSingle(startFrame, () => {
|
|
||||||
this.renderFrame(startFrame);
|
|
||||||
for (let i = 1; i <= TOTAL_FRAMES; i++) {
|
|
||||||
if (i !== startFrame) loadSingle(i);
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFramePath(index: number): string {
|
private getFramePath(index: number): string {
|
||||||
|
|
@ -197,7 +131,6 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private onScroll = (): void => {
|
private onScroll = (): void => {
|
||||||
if (typeof window !== 'undefined' && window.innerWidth <= 900) return;
|
|
||||||
if (!this.isVisible || !this.containerRef || this.isReducedMotion) return;
|
if (!this.isVisible || !this.containerRef || this.isReducedMotion) return;
|
||||||
|
|
||||||
const container = this.containerRef.nativeElement;
|
const container = this.containerRef.nativeElement;
|
||||||
|
|
@ -210,25 +143,10 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
const progress = Math.min(Math.max(rawProgress, 0), 1);
|
const progress = Math.min(Math.max(rawProgress, 0), 1);
|
||||||
this.targetFrame = Math.min(Math.max(1, 1 + progress * (TOTAL_FRAMES - 1)), TOTAL_FRAMES);
|
this.targetFrame = Math.min(Math.max(1, 1 + progress * (TOTAL_FRAMES - 1)), TOTAL_FRAMES);
|
||||||
|
|
||||||
const jumpDiff = Math.abs(this.targetFrame - this.currentInterpolatedFrame);
|
|
||||||
if (jumpDiff > 40 && !this.isLoopRunning) {
|
|
||||||
this.currentInterpolatedFrame = this.targetFrame;
|
|
||||||
this.renderFrame(Math.round(this.targetFrame));
|
|
||||||
this.updateActivePhase(this.targetFrame);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.startAnimationLoop();
|
this.startAnimationLoop();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onResize = (): void => {
|
private onResize = (): void => {
|
||||||
if (typeof window !== 'undefined' && window.innerWidth <= 900) {
|
|
||||||
if (this.animationFrameId !== null) {
|
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
this.isLoopRunning = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.resizeCanvas();
|
this.resizeCanvas();
|
||||||
this.renderFrame(Math.round(this.currentInterpolatedFrame));
|
this.renderFrame(Math.round(this.currentInterpolatedFrame));
|
||||||
};
|
};
|
||||||
|
|
@ -281,7 +199,7 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
} else if (progress < 0.94) {
|
} else if (progress < 0.94) {
|
||||||
phase = 3;
|
phase = 3;
|
||||||
} else {
|
} else {
|
||||||
phase = 0;
|
phase = 0; // Desvanecimiento completo al final del scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activePhase() !== phase) {
|
if (this.activePhase() !== phase) {
|
||||||
|
|
@ -304,15 +222,15 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
private getBestFrame(targetIndex: number): HTMLImageElement | null {
|
private getBestFrame(targetIndex: number): HTMLImageElement | null {
|
||||||
const rounded = Math.min(Math.max(1, Math.round(targetIndex)), TOTAL_FRAMES);
|
const rounded = Math.min(Math.max(1, Math.round(targetIndex)), TOTAL_FRAMES);
|
||||||
if (SHARED_IMAGE_CACHE[rounded]?.complete) return SHARED_IMAGE_CACHE[rounded];
|
if (this.images[rounded]?.complete) return this.images[rounded];
|
||||||
|
|
||||||
for (let offset = 1; offset < TOTAL_FRAMES; offset++) {
|
for (let offset = 1; offset < TOTAL_FRAMES; offset++) {
|
||||||
const prev = rounded - offset;
|
const prev = rounded - offset;
|
||||||
if (prev >= 1 && SHARED_IMAGE_CACHE[prev]?.complete) return SHARED_IMAGE_CACHE[prev];
|
if (prev >= 1 && this.images[prev]?.complete) return this.images[prev];
|
||||||
const next = rounded + offset;
|
const next = rounded + offset;
|
||||||
if (next <= TOTAL_FRAMES && SHARED_IMAGE_CACHE[next]?.complete) return SHARED_IMAGE_CACHE[next];
|
if (next <= TOTAL_FRAMES && this.images[next]?.complete) return this.images[next];
|
||||||
}
|
}
|
||||||
return SHARED_IMAGE_CACHE[1]?.complete ? SHARED_IMAGE_CACHE[1] : null;
|
return this.images[1]?.complete ? this.images[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderFrame(targetIndex: number): void {
|
private renderFrame(targetIndex: number): void {
|
||||||
|
|
@ -329,13 +247,25 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const imgAspect = 16 / 9;
|
const imgAspect = 16 / 9;
|
||||||
const drawW = canvas.width;
|
const screenAspect = canvas.width / canvas.height;
|
||||||
const drawH = canvas.width / imgAspect;
|
let drawW: number;
|
||||||
const offsetY = (canvas.height - drawH) / 2;
|
let drawH: number;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (screenAspect > imgAspect) {
|
||||||
|
drawW = canvas.width;
|
||||||
|
drawH = canvas.width / imgAspect;
|
||||||
|
offsetY = (canvas.height - drawH) / 2;
|
||||||
|
} else {
|
||||||
|
drawH = canvas.height;
|
||||||
|
drawW = canvas.height * imgAspect;
|
||||||
|
offsetX = (canvas.width - drawW) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
ctx.drawImage(img, 0, offsetY, drawW, drawH);
|
ctx.drawImage(img, offsetX, offsetY, drawW, drawH);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
color: var(--zen-text-primary, #f0f3f8);
|
|
||||||
font-family: var(--font-sans, 'Plus Jakarta Sans', -apple-system, sans-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.soc-dashboard {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.soc-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--zen-border-subtle, #1e293b);
|
|
||||||
padding-bottom: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-area { display: flex; align-items: center; gap: 0.85rem; }
|
|
||||||
.shield-badge {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 44px; height: 44px;
|
|
||||||
background: rgba(56, 189, 248, 0.12);
|
|
||||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
|
||||||
border-radius: var(--zen-radius-sm, 4px);
|
|
||||||
color: #38bdf8;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.shield-icon { width: 22px; height: 22px; }
|
|
||||||
.soc-title { font-size: 1.35rem; font-weight: 700; color: #38bdf8; margin: 0; }
|
|
||||||
.soc-subtitle { font-size: 0.78rem; color: var(--zen-text-muted, #94a3b8); margin: 0.2rem 0 0; font-family: var(--font-mono, monospace); }
|
|
||||||
.engine-badge {
|
|
||||||
background: #111827; color: #94a3b8; padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: var(--zen-radius-xs, 4px); font-size: 0.78rem; font-family: var(--font-mono, monospace);
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-panel, .results-dashboard {
|
|
||||||
background: #111827; border: 1px solid #1f2937;
|
|
||||||
border-radius: var(--zen-radius-sm, 6px); padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tag { display: inline-block; font-family: var(--font-mono, monospace); font-size: 0.72rem; color: #e2b94a; margin-bottom: 0.3rem; }
|
|
||||||
.panel-title { font-size: 1.15rem; font-weight: 600; color: #f8fafc; margin: 0 0 1.25rem; }
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 1.25rem; margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
|
|
||||||
.form-group input[type="text"], .form-group input[type="email"], .form-group select {
|
|
||||||
width: 100%; padding: 0.7rem 0.85rem; background: #0f172a; border: 1px solid #334155;
|
|
||||||
color: #f8fafc; border-radius: var(--zen-radius-xs, 4px); box-sizing: border-box;
|
|
||||||
font-family: var(--font-mono, monospace); font-size: 0.88rem; transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.form-group input:focus, .form-group select:focus { outline: none; border-color: #38bdf8; }
|
|
||||||
|
|
||||||
.target-presets { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; flex-wrap: wrap; }
|
|
||||||
.preset-label { font-size: 0.72rem; color: #64748b; font-family: var(--font-mono, monospace); }
|
|
||||||
.preset-btn {
|
|
||||||
background: #1e293b; border: 1px solid #334155; color: #93c5fd; font-size: 0.7rem;
|
|
||||||
font-family: var(--font-mono, monospace); padding: 0.15rem 0.45rem; border-radius: 3px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.preset-btn:hover { background: #334155; color: #ffffff; }
|
|
||||||
|
|
||||||
.checkbox-group { display: flex; align-items: center; }
|
|
||||||
.checkbox-label { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-size: 0.85rem; color: #cbd5e1; }
|
|
||||||
.checkbox-label input[type="checkbox"] { width: 18px; height: 18px; accent-color: #0284c7; cursor: pointer; }
|
|
||||||
|
|
||||||
.launch-btn {
|
|
||||||
width: 100%; padding: 0.85rem 1.25rem;
|
|
||||||
background: linear-gradient(135deg, #0284c7 0%, #2563eb 100%);
|
|
||||||
color: #ffffff; border: none; border-radius: var(--zen-radius-sm, 6px);
|
|
||||||
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
.launch-btn:hover:not(:disabled) { opacity: 0.92; }
|
|
||||||
.launch-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
.btn-icon { width: 18px; height: 18px; }
|
|
||||||
.spinner-dot {
|
|
||||||
width: 10px; height: 10px; border-radius: 50%; background: #38bdf8;
|
|
||||||
animation: pulse-spin 1s infinite alternate;
|
|
||||||
}
|
|
||||||
@keyframes pulse-spin { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.3); opacity: 1; } }
|
|
||||||
|
|
||||||
.alert-box.error {
|
|
||||||
background: #450a0a; color: #fca5a5; border: 1px solid #7f1d1d;
|
|
||||||
padding: 1rem; border-radius: var(--zen-radius-sm, 4px); margin-bottom: 1.5rem;
|
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.alert-icon { width: 20px; height: 20px; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.results-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
border-bottom: 1px solid #1e293b; padding-bottom: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.results-title-group { display: flex; align-items: center; gap: 0.6rem; }
|
|
||||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; }
|
|
||||||
.results-header h2 { font-size: 1.15rem; font-weight: 600; color: #f8fafc; margin: 0; }
|
|
||||||
.header-badges { display: flex; align-items: center; gap: 0.6rem; }
|
|
||||||
.status-pill.status-completed {
|
|
||||||
background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.4);
|
|
||||||
padding: 0.3rem 0.65rem; border-radius: var(--zen-radius-xs, 4px); font-size: 0.75rem;
|
|
||||||
font-family: var(--font-mono, monospace); font-weight: 600;
|
|
||||||
}
|
|
||||||
.reset-btn {
|
|
||||||
background: #1e293b; color: #94a3b8; border: 1px solid #334155; padding: 0.3rem 0.65rem;
|
|
||||||
border-radius: var(--zen-radius-xs, 4px); font-size: 0.75rem; cursor: pointer;
|
|
||||||
}
|
|
||||||
.reset-btn:hover { background: #334155; color: #f8fafc; }
|
|
||||||
|
|
||||||
.metrics-grid {
|
|
||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1rem; margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.metric-card {
|
|
||||||
background: #1e293b; border-radius: var(--zen-radius-xs, 4px); padding: 1.1rem;
|
|
||||||
text-align: center; border: 1px solid #2d3748;
|
|
||||||
}
|
|
||||||
.metric-title { display: block; font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.5rem; text-transform: uppercase; }
|
|
||||||
.verdict-tag {
|
|
||||||
display: inline-block; padding: 0.35rem 0.85rem; border-radius: var(--zen-radius-xs, 4px);
|
|
||||||
font-weight: 700; font-size: 0.85rem; font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
.verdict-tag.critical_threat { background: #991b1b; color: #fee2e2; border: 1px solid #b91c1c; }
|
|
||||||
.verdict-tag.attention_required { background: #854d0e; color: #fef08a; border: 1px solid #a16207; }
|
|
||||||
.verdict-tag.safe { background: #166534; color: #dcfce7; border: 1px solid #15803d; }
|
|
||||||
|
|
||||||
.risk-gauge { font-size: 1.6rem; font-weight: 800; font-family: var(--font-mono, monospace); }
|
|
||||||
.risk-gauge.high-risk { color: #f87171; }
|
|
||||||
.risk-gauge.med-risk { color: #facc15; }
|
|
||||||
.risk-gauge.safe-risk { color: #4ade80; }
|
|
||||||
.gauge-max { font-size: 0.9rem; color: #64748b; font-weight: 400; }
|
|
||||||
.metric-value.highlight { font-size: 1.6rem; font-weight: 700; color: #38bdf8; font-family: var(--font-mono, monospace); }
|
|
||||||
.containment-value { font-size: 0.88rem; font-weight: 600; color: #94a3b8; }
|
|
||||||
.containment-value.contained { color: #4ade80; }
|
|
||||||
|
|
||||||
.mitre-section { margin-bottom: 1.5rem; }
|
|
||||||
.mitre-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.6rem; }
|
|
||||||
.mitre-label { font-size: 0.85rem; color: #cbd5e1; font-weight: 600; }
|
|
||||||
.mitre-count { font-size: 0.75rem; color: #94a3b8; font-family: var(--font-mono, monospace); }
|
|
||||||
.mitre-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
||||||
.chip {
|
|
||||||
background: #1e293b; border: 1px solid #334155; color: #cbd5e1;
|
|
||||||
padding: 0.35rem 0.75rem; border-radius: var(--zen-radius-xs, 4px);
|
|
||||||
font-family: var(--font-mono, monospace); font-size: 0.82rem; display: flex; align-items: center; gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: #38bdf8; }
|
|
||||||
|
|
||||||
.forensic-box {
|
|
||||||
background: #090d16; border: 1px dashed #38bdf8; border-radius: var(--zen-radius-xs, 4px);
|
|
||||||
padding: 1.25rem; margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.forensic-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.85rem; flex-wrap: wrap; gap: 0.5rem; }
|
|
||||||
.forensic-title { font-size: 0.88rem; font-weight: 600; color: #38bdf8; }
|
|
||||||
.timestamp-tag { font-size: 0.72rem; color: #64748b; font-family: var(--font-mono, monospace); }
|
|
||||||
.forensic-body p { margin: 0.4rem 0; font-size: 0.82rem; color: #cbd5e1; }
|
|
||||||
.forensic-body code {
|
|
||||||
font-family: var(--font-mono, monospace); font-size: 0.82rem;
|
|
||||||
background: rgba(15, 23, 42, 0.8); padding: 0.15rem 0.4rem; border-radius: 3px; color: #93c5fd;
|
|
||||||
}
|
|
||||||
.hash-code { color: #4ade80 !important; word-break: break-all; }
|
|
||||||
.action-buttons { margin-top: 1rem; }
|
|
||||||
.report-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.65rem 1.25rem;
|
|
||||||
background: #0284c7; color: #ffffff; text-decoration: none; border-radius: var(--zen-radius-xs, 4px);
|
|
||||||
font-size: 0.85rem; font-weight: 500; transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.report-btn:hover { background: #0369a1; }
|
|
||||||
.report-icon { width: 16px; height: 16px; }
|
|
||||||
.external-arrow { font-size: 0.95rem; }
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
<div class="soc-dashboard" aria-label="Autonomous Cyber-Defense SOC Dashboard">
|
|
||||||
<!-- Encabezado Táctico del SOC -->
|
|
||||||
<header class="soc-header">
|
|
||||||
<div class="logo-area">
|
|
||||||
<div class="shield-badge">
|
|
||||||
<svg class="shield-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
|
|
||||||
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="soc-title">Autonomous Cyber-Defense & Purple Teaming</h1>
|
|
||||||
<p class="soc-subtitle">Triage Cognitivo DeepSeek // Orquestador n8n // Evidencia Criptográfica Gotenberg</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="engine-badge">Engine: n8n + DeepSeek + Gotenberg</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Panel de Configuración de la Simulación -->
|
|
||||||
<section class="control-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-tag">SECOPS // OPERATIONS DISPATCHER</span>
|
|
||||||
<h2 class="panel-title">Lanzador de Operaciones de Ciberdefensa</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<!-- Modo de Operación -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="operation-mode">Modo de Operación:</label>
|
|
||||||
<select
|
|
||||||
id="operation-mode"
|
|
||||||
[ngModel]="mode()"
|
|
||||||
(ngModelChange)="mode.set($event)"
|
|
||||||
[disabled]="isLoading()"
|
|
||||||
>
|
|
||||||
<option value="PURPLE_TEAM">PURPLE TEAM (Adversary Emulation + Defense)</option>
|
|
||||||
<option value="EASM">EASM (Superficie de Ataque Externa)</option>
|
|
||||||
<option value="SOAR">SOAR (Enriquecimiento e Inteligencia)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Objetivo -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="operation-target">Objetivo (Target / FQDN / IP):</label>
|
|
||||||
<input
|
|
||||||
id="operation-target"
|
|
||||||
type="text"
|
|
||||||
[ngModel]="target()"
|
|
||||||
(ngModelChange)="target.set($event)"
|
|
||||||
placeholder="local_sandbox o IP/Dominio"
|
|
||||||
[disabled]="isLoading()"
|
|
||||||
/>
|
|
||||||
<div class="target-presets">
|
|
||||||
<span class="preset-label">Presets:</span>
|
|
||||||
<button type="button" class="preset-btn" (click)="setTargetPreset('local_sandbox')">local_sandbox</button>
|
|
||||||
<button type="button" class="preset-btn" (click)="setTargetPreset('gateway.perimeter.internal')">gateway.perimeter</button>
|
|
||||||
<button type="button" class="preset-btn" (click)="setTargetPreset('api.bryan-bano.com')">api.bryan-bano.com</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auditor / Solicitante -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="operation-auditor">Auditor / Solicitante:</label>
|
|
||||||
<input
|
|
||||||
id="operation-auditor"
|
|
||||||
type="email"
|
|
||||||
[ngModel]="requestedBy()"
|
|
||||||
(ngModelChange)="requestedBy.set($event)"
|
|
||||||
placeholder="auditor@empresa.com"
|
|
||||||
[disabled]="isLoading()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aislamiento Zero-Trust -->
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<label class="checkbox-label" for="operation-isolate">
|
|
||||||
<input
|
|
||||||
id="operation-isolate"
|
|
||||||
type="checkbox"
|
|
||||||
[ngModel]="autoIsolate()"
|
|
||||||
(ngModelChange)="autoIsolate.set($event)"
|
|
||||||
[disabled]="isLoading()"
|
|
||||||
/>
|
|
||||||
<span class="checkbox-text">Aislamiento y Contención Automática (Zero-Trust)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botón de Lanzamiento -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="launch-btn"
|
|
||||||
(click)="executeSimulation()"
|
|
||||||
[disabled]="isLoading()"
|
|
||||||
>
|
|
||||||
@if (!isLoading()) {
|
|
||||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
|
||||||
</svg>
|
|
||||||
<span>Iniciar Simulación de Ciberdefensa</span>
|
|
||||||
} @else {
|
|
||||||
<span class="spinner-dot"></span>
|
|
||||||
<span>Ejecutando Ciclo Cerrado (Triage Cognitivo DeepSeek)...</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Estado de Alerta de Error -->
|
|
||||||
@if (errorMessage(); as error) {
|
|
||||||
<div class="alert-box error" role="alert">
|
|
||||||
<svg class="alert-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
<div class="alert-content">
|
|
||||||
<strong>Error en Operación:</strong> {{ error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Dashboard de Resultados en Tiempo Real -->
|
|
||||||
@if (result(); as res) {
|
|
||||||
<section class="results-dashboard" aria-live="polite">
|
|
||||||
<div class="results-header">
|
|
||||||
<div class="results-title-group">
|
|
||||||
<span class="live-dot"></span>
|
|
||||||
<h2>Telemetría Forense Consolidada</h2>
|
|
||||||
</div>
|
|
||||||
<div class="header-badges">
|
|
||||||
<span class="status-pill status-completed">COMPLETADO (200 OK)</span>
|
|
||||||
<button type="button" class="reset-btn" (click)="resetSimulation()" title="Nueva Simulación">
|
|
||||||
Nueva Consulta
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metrics-grid">
|
|
||||||
<!-- Veredicto Cognitivo -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-title">Veredicto DeepSeek</span>
|
|
||||||
<div class="verdict-tag" [ngClass]="res.summary.verdict.toLowerCase()">
|
|
||||||
{{ res.summary.verdict }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Risk Score -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-title">Risk Score</span>
|
|
||||||
<div
|
|
||||||
class="risk-gauge"
|
|
||||||
[ngClass]="res.summary.risk_score > 70 ? 'high-risk' : (res.summary.risk_score > 30 ? 'med-risk' : 'safe-risk')"
|
|
||||||
>
|
|
||||||
{{ res.summary.risk_score }} <span class="gauge-max">/ 100</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Latencia de Mitigación -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-title">Latencia de Mitigación (Δt)</span>
|
|
||||||
<div class="metric-value highlight">{{ res.summary.mitigation_time_ms }} ms</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contención Docker / Red -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-title">Contención de Red</span>
|
|
||||||
<div class="metric-value containment-value" [class.contained]="res.summary.containment_executed">
|
|
||||||
{{ res.summary.containment_executed ? 'Aislado (Host Bridge Detached)' : 'Monitoreo Activo' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Técnicas MITRE ATT&CK -->
|
|
||||||
<div class="mitre-section">
|
|
||||||
<div class="mitre-header">
|
|
||||||
<span class="mitre-label">Técnicas MITRE ATT&CK v14 Correlacionadas</span>
|
|
||||||
<span class="mitre-count">{{ res.summary.mitre_techniques.length }} detectadas</span>
|
|
||||||
</div>
|
|
||||||
<div class="mitre-chips">
|
|
||||||
@for (tech of res.summary.mitre_techniques; track tech) {
|
|
||||||
<span class="chip">
|
|
||||||
<span class="chip-dot"></span>
|
|
||||||
{{ tech }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Evidencia Forense & Hash Criptográfico -->
|
|
||||||
<div class="forensic-box">
|
|
||||||
<div class="forensic-header">
|
|
||||||
<span class="forensic-title">Evidencia Forense Criptográfica (Principio de No Repudio)</span>
|
|
||||||
<span class="timestamp-tag">{{ res.timestamp }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="forensic-body">
|
|
||||||
<p><strong>Simulation UUID:</strong> <code>{{ res.simulation_id }}</code></p>
|
|
||||||
<p><strong>Execution ID:</strong> <code>{{ res.execution_id }}</code></p>
|
|
||||||
<p><strong>Firma SHA-256:</strong> <code class="hash-code">{{ res.evidence.sha256_hash }}</code></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<a
|
|
||||||
[href]="res.evidence.report_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="report-btn"
|
|
||||||
>
|
|
||||||
<svg class="report-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
|
||||||
</svg>
|
|
||||||
<span>Ver Reporte Forense en Navegador</span>
|
|
||||||
<span class="external-arrow">↗</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { SecurityAgentService } from '../../../services/security-agent.service';
|
|
||||||
import { RetroAudioService } from '../../../application/services/retro-audio.service';
|
|
||||||
import {
|
|
||||||
SimulationMode,
|
|
||||||
SecurityAgentRequest,
|
|
||||||
SecurityAgentResponse
|
|
||||||
} from '../../../domain/models/security.model';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-incident-simulator, app-cyber-defense',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
templateUrl: './incident-simulator.component.html',
|
|
||||||
styleUrl: './incident-simulator.component.css',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class IncidentSimulatorComponent {
|
|
||||||
private readonly securityService = inject(SecurityAgentService);
|
|
||||||
private readonly audio = inject(RetroAudioService);
|
|
||||||
|
|
||||||
// Parámetros reactivos de configuración
|
|
||||||
readonly mode = signal<SimulationMode>('PURPLE_TEAM');
|
|
||||||
readonly target = signal<string>('local_sandbox');
|
|
||||||
readonly requestedBy = signal<string>('auditor@bryan-bano.com');
|
|
||||||
readonly autoIsolate = signal<boolean>(true);
|
|
||||||
|
|
||||||
// Estados de ejecución y telemetría
|
|
||||||
readonly isLoading = signal<boolean>(false);
|
|
||||||
readonly errorMessage = signal<string | null>(null);
|
|
||||||
readonly result = signal<SecurityAgentResponse | null>(null);
|
|
||||||
|
|
||||||
setTargetPreset(value: string): void {
|
|
||||||
this.audio.playClick();
|
|
||||||
this.target.set(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
executeSimulation(): void {
|
|
||||||
if (this.isLoading()) return;
|
|
||||||
|
|
||||||
this.audio.playClick();
|
|
||||||
this.isLoading.set(true);
|
|
||||||
this.errorMessage.set(null);
|
|
||||||
this.result.set(null);
|
|
||||||
|
|
||||||
const payload: SecurityAgentRequest = {
|
|
||||||
mode: this.mode(),
|
|
||||||
target: this.target().trim() || 'local_sandbox',
|
|
||||||
requested_by: this.requestedBy().trim() || 'auditor@bryan-bano.com',
|
|
||||||
parameters: {
|
|
||||||
auto_isolate: this.autoIsolate(),
|
|
||||||
scan_depth: 'full'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.securityService.triggerSimulation(payload).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.result.set(response);
|
|
||||||
this.isLoading.set(false);
|
|
||||||
this.audio.playVictory();
|
|
||||||
},
|
|
||||||
error: (err: Error) => {
|
|
||||||
this.errorMessage.set(err.message || 'Error al conectar con el orquestador n8n.');
|
|
||||||
this.isLoading.set(false);
|
|
||||||
this.audio.playGameOver();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSimulation(): void {
|
|
||||||
this.audio.playClick();
|
|
||||||
this.errorMessage.set(null);
|
|
||||||
this.result.set(null);
|
|
||||||
this.isLoading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
IncidentSimulatorComponent as CyberDefenseComponent,
|
|
||||||
IncidentSimulatorComponent as CyberDefenseDashboardComponent
|
|
||||||
};
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #121517;
|
background-color: var(--zen-bg-base);
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11,13 +11,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #121517;
|
background-color: var(--zen-bg-base);
|
||||||
}
|
|
||||||
|
|
||||||
.landing-container.transitioning-lock {
|
|
||||||
overflow: hidden !important;
|
|
||||||
height: 100vh !important;
|
|
||||||
max-height: 100vh !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header Minimalista Flotante */
|
/* Header Minimalista Flotante */
|
||||||
|
|
@ -31,81 +25,37 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 clamp(0.75rem, 2vw, 2rem);
|
padding: 0 clamp(1rem, 3vw, 2.5rem);
|
||||||
background: rgba(10, 10, 11, 0.85);
|
background: rgba(10, 10, 11, 0.75);
|
||||||
backdrop-filter: blur(14px) saturate(160%);
|
backdrop-filter: blur(12px) saturate(160%);
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block-btn {
|
.brand-block {
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
gap: var(--space-2);
|
||||||
}
|
|
||||||
|
|
||||||
.brand-logo {
|
|
||||||
height: 30px;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: var(--zen-radius-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Barra de Navegación Rápida */
|
|
||||||
.zen-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-nav::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
letter-spacing: 0.04em;
|
||||||
padding: 5px 10px;
|
}
|
||||||
|
|
||||||
|
.brand-monogram {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--zen-accent-matcha);
|
||||||
|
background: rgba(158, 206, 106, 0.12);
|
||||||
|
padding: 2px 6px;
|
||||||
border-radius: var(--zen-radius-xs);
|
border-radius: var(--zen-radius-xs);
|
||||||
cursor: pointer;
|
border: 1px solid rgba(158, 206, 106, 0.25);
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.brand-separator { color: var(--zen-border-subtle); }
|
||||||
color: var(--zen-text-primary);
|
.brand-name { font-weight: 700; color: var(--zen-text-primary); }
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
background: rgba(197, 155, 39, 0.1);
|
|
||||||
border-color: rgba(197, 155, 39, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.nav-all {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right-group {
|
.header-right-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-4);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-location-block {
|
.time-location-block {
|
||||||
|
|
@ -118,14 +68,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-clock {
|
.live-clock {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-label {
|
.location-label {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.75rem;
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +87,7 @@
|
||||||
background: var(--zen-bg-surface);
|
background: var(--zen-bg-surface);
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: var(--zen-radius-xs);
|
border-radius: var(--zen-radius-xs);
|
||||||
padding: 4px 8px;
|
padding: 4px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
@ -148,134 +98,7 @@
|
||||||
background: var(--zen-bg-elevated);
|
background: var(--zen-bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-container {
|
.hero-container { width: 100%; }
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vista Aislada (Single Component View) */
|
|
||||||
.isolated-view {
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
padding-top: calc(56px + var(--space-4));
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: #121517;
|
|
||||||
flex: 1 0 auto;
|
|
||||||
animation: zenViewEnter 0.32s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes zenViewEnter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-container.isolated-view {
|
|
||||||
padding-top: 0;
|
|
||||||
background-color: #000000;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view {
|
|
||||||
height: 100vh;
|
|
||||||
min-height: 100vh;
|
|
||||||
max-height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 56px clamp(1rem, 3vw, 2.5rem) 0 clamp(1rem, 3vw, 2.5rem);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .stage-grid {
|
|
||||||
height: 100%;
|
|
||||||
max-height: calc(100vh - 72px);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .retro-module-card {
|
|
||||||
height: 100%;
|
|
||||||
max-height: calc(100vh - 72px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .retro-module-card app-retro-os {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .vintage-monitor-wrapper {
|
|
||||||
max-height: calc(100vh - 90px);
|
|
||||||
width: auto;
|
|
||||||
aspect-ratio: 1398 / 1564;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .profile-module-card {
|
|
||||||
max-height: calc(100vh - 72px);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.hero-container.isolated-view {
|
|
||||||
padding: calc(56px + var(--space-4)) clamp(0.75rem, 3vw, 1.5rem) var(--space-8);
|
|
||||||
background-color: #121517;
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
height: auto;
|
|
||||||
overflow-y: visible;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view {
|
|
||||||
height: auto;
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
max-height: none;
|
|
||||||
overflow-y: visible;
|
|
||||||
display: block;
|
|
||||||
padding: calc(56px + var(--space-4)) clamp(0.75rem, 3vw, 1.5rem) var(--space-12) clamp(0.75rem, 3vw, 1.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .stage-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
gap: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .retro-module-card {
|
|
||||||
order: 1;
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .profile-module-card {
|
|
||||||
order: 2;
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
overflow: visible;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive-stage.isolated-view .vintage-monitor-wrapper {
|
|
||||||
max-height: none;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
/* ==============================================================================
|
||||||
MÓDULOS INTERACTIVOS (50/50 HORIZONTAL)
|
MÓDULOS INTERACTIVOS (50/50 HORIZONTAL)
|
||||||
|
|
@ -283,7 +106,7 @@
|
||||||
|
|
||||||
.interactive-stage {
|
.interactive-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem) var(--space-12) clamp(1rem, 3vw, 2.5rem);
|
padding: var(--space-12) clamp(1rem, 3vw, 2.5rem) var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
|
background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
|
||||||
}
|
}
|
||||||
|
|
@ -325,87 +148,34 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-module-card app-technical-profile {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
/* ==============================================================================
|
||||||
SECCIÓN DE PROYECTOS DESTACADOS
|
SECCIÓN DE PROYECTOS DESTACADOS (CASOS REALES A ANCHO COMPLETO)
|
||||||
============================================================================== */
|
============================================================================== */
|
||||||
|
|
||||||
.projects-stage {
|
.projects-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
box-sizing: border-box;
|
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
}
|
|
||||||
|
|
||||||
.projects-stage.isolated-view {
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
padding-top: calc(56px + var(--space-6));
|
|
||||||
padding-bottom: var(--space-8);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #121517;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-stage-inner {
|
.projects-stage-inner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1540px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==============================================================================
|
/* ==============================================================================
|
||||||
SECCIÓN: SIMULADOR DE RESPUESTA A INCIDENTES CLOUD
|
SECCIÓN DEDICADA: TERMINAL ARCH LINUX (AMPLIA Y CONFORTABLE)
|
||||||
============================================================================== */
|
|
||||||
|
|
||||||
.simulator-stage {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-12) clamp(1rem, 3vw, 2.5rem);
|
|
||||||
background-color: #121517;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simulator-stage.isolated-view {
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
padding-top: calc(56px + var(--space-4));
|
|
||||||
padding-bottom: var(--space-6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #121517;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
|
||||||
SECCIÓN: TERMINAL ARCH LINUX
|
|
||||||
============================================================================== */
|
============================================================================== */
|
||||||
|
|
||||||
.terminal-stage {
|
.terminal-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
box-sizing: border-box;
|
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-stage.isolated-view {
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
padding-top: calc(56px + var(--space-4));
|
|
||||||
padding-bottom: var(--space-6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #121517;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-stage-inner {
|
.terminal-stage-inner {
|
||||||
max-width: 1540px;
|
max-width: 1040px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -413,277 +183,62 @@
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cabecera Normalizada de Sección */
|
.terminal-header-meta {
|
||||||
.terminal-section-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 4px;
|
||||||
width: 100%;
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-section-header .header-left,
|
.mono-section-tag {
|
||||||
.terminal-section-header .title-with-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-section-header .pulse-indicator {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 0 12px var(--zen-accent-kintsugi);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-section-header .section-title {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
font-size: 0.75rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
color: var(--zen-accent-kintsugi);
|
||||||
color: var(--zen-text-primary);
|
letter-spacing: 0.12em;
|
||||||
line-height: 1.15;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==============================================================================
|
.terminal-section-heading {
|
||||||
SECCIÓN: CONTACTO
|
font-family: var(--font-sans);
|
||||||
============================================================================== */
|
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
.contact-stage {
|
color: var(--zen-text-primary);
|
||||||
width: 100%;
|
margin: 0;
|
||||||
background-color: #121517;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-stage.isolated-view {
|
.terminal-section-desc {
|
||||||
min-height: calc(100vh - 56px);
|
font-family: var(--font-sans);
|
||||||
padding-top: 56px;
|
font-size: 0.9375rem;
|
||||||
background-color: #121517;
|
color: var(--zen-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 680px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==============================================================================
|
/* Footer Minimalista */
|
||||||
FLECHAS SUTILES FLOTANTES DE NAVEGACIÓN (DEBAJO DEL NAVBAR)
|
.zen-footer {
|
||||||
============================================================================== */
|
margin-top: auto;
|
||||||
|
|
||||||
.zen-floating-nav-controls {
|
|
||||||
position: fixed;
|
|
||||||
top: 68px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 950;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 clamp(0.75rem, 2.5vw, 2.5rem);
|
padding: var(--space-6) clamp(1rem, 3vw, 2.5rem);
|
||||||
box-sizing: border-box;
|
border-top: 1px solid var(--zen-border-subtle);
|
||||||
|
background-color: #121517;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen-nav-arrow-btn {
|
.footer-copy {
|
||||||
pointer-events: auto;
|
font-family: var(--font-sans);
|
||||||
display: inline-flex;
|
font-size: 0.8125rem;
|
||||||
align-items: center;
|
color: var(--zen-text-muted);
|
||||||
gap: 8px;
|
}
|
||||||
background: rgba(10, 13, 20, 0.75);
|
|
||||||
backdrop-filter: blur(12px) saturate(160%);
|
.footer-version {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
font-family: var(--font-sans);
|
||||||
border-radius: 999px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
color: #9aa5b8;
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
color: var(--zen-accent-matcha);
|
||||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
background: rgba(158, 206, 106, 0.08);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
padding: 2px 6px;
|
||||||
opacity: 1;
|
border-radius: var(--zen-radius-xs);
|
||||||
}
|
|
||||||
|
|
||||||
.zen-nav-arrow-btn:hover:not(:disabled) {
|
|
||||||
color: #f0f3f8;
|
|
||||||
border-color: rgba(197, 155, 39, 0.5);
|
|
||||||
background: rgba(18, 22, 32, 0.9);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 0 14px rgba(197, 155, 39, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-nav-arrow-btn.disabled,
|
|
||||||
.zen-nav-arrow-btn:disabled {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: scale(0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-arrow-svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
stroke: #c59b27;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-arrow-left:hover:not(:disabled) .nav-arrow-svg {
|
|
||||||
transform: translateX(-3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-arrow-right:hover:not(:disabled) .nav-arrow-svg {
|
|
||||||
transform: translateX(3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-arrow-label {
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
|
||||||
TELÓN DE TRANSICIÓN SUAVE Y SCANLINE ZEN
|
|
||||||
============================================================================== */
|
|
||||||
|
|
||||||
.zen-transition-curtain {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
background: rgba(8, 10, 14, 0.94);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-transition-curtain.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curtain-logo-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 14px;
|
|
||||||
animation: logoWrapperEnter 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-glow-ring {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle, rgba(197, 155, 39, 0.5) 0%, rgba(56, 189, 248, 0.2) 50%, transparent 70%);
|
|
||||||
filter: blur(16px);
|
|
||||||
animation: logoPulseGlow 1.2s ease-in-out infinite alternate;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-image-container {
|
|
||||||
position: relative;
|
|
||||||
width: 78px;
|
|
||||||
height: 78px;
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(197, 155, 39, 0.5);
|
|
||||||
box-shadow: 0 0 28px rgba(197, 155, 39, 0.35), 0 8px 32px rgba(0, 0, 0, 0.85);
|
|
||||||
background: #000000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curtain-logo-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transform: scale(0.92);
|
|
||||||
animation: logoBreath 1.2s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-shimmer-sweep {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(110deg, transparent 25%, rgba(255, 255, 255, 0.45) 50%, transparent 75%);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
animation: logoShimmer 1.0s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoWrapperEnter {
|
|
||||||
from { opacity: 0; transform: scale(0.92); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoPulseGlow {
|
|
||||||
0% { transform: scale(0.85); opacity: 0.4; }
|
|
||||||
100% { transform: scale(1.3); opacity: 0.95; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoBreath {
|
|
||||||
0% { transform: scale(0.88); filter: drop-shadow(0 0 4px rgba(197, 155, 39, 0.3)); }
|
|
||||||
100% { transform: scale(0.96); filter: drop-shadow(0 0 12px rgba(197, 155, 39, 0.7)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoShimmer {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.zen-header {
|
|
||||||
padding: 0 8px;
|
|
||||||
gap: 6px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-location-block {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-nav {
|
|
||||||
gap: 3px;
|
|
||||||
padding: 3px 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 20px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
padding: 4px 7px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
border-radius: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-btn {
|
|
||||||
padding: 3px 6px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-floating-nav-controls {
|
|
||||||
top: 58px;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-arrow-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-nav-arrow-btn {
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,11 @@
|
||||||
<div class="landing-container" [class.transitioning-lock]="isCurtainVisible()">
|
<div class="landing-container">
|
||||||
<!-- Header Minimalista Flotante (Glassmorphism & Zen High-Contrast) -->
|
<!-- Header Minimalista Flotante (Glassmorphism & Zen High-Contrast) -->
|
||||||
<header class="zen-header">
|
<header class="zen-header">
|
||||||
<button type="button" class="brand-block-btn" (click)="setSection('hero')" [attr.aria-label]="i18n.t().navHome">
|
<div class="brand-block">
|
||||||
<img src="assets/images/logo.jpg" alt="Bryan Baño" class="brand-logo" />
|
<span class="brand-monogram">BRYAN</span>
|
||||||
</button>
|
<span class="brand-separator">/</span>
|
||||||
|
<span class="brand-name">BAÑO</span>
|
||||||
<!-- Barra de Navegación Rápida Zen -->
|
</div>
|
||||||
<nav #navContainer class="zen-nav" aria-label="Navegación principal">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
data-nav="hero"
|
|
||||||
[class.active]="activeSection() === 'hero'"
|
|
||||||
(click)="setSection('hero')"
|
|
||||||
>
|
|
||||||
{{ i18n.t().navHome }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
data-nav="retro"
|
|
||||||
[class.active]="activeSection() === 'retro'"
|
|
||||||
(click)="setSection('retro')"
|
|
||||||
>
|
|
||||||
{{ i18n.t().navRetro }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
data-nav="projects"
|
|
||||||
[class.active]="activeSection() === 'projects'"
|
|
||||||
(click)="setSection('projects')"
|
|
||||||
>
|
|
||||||
{{ i18n.t().navProjects }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
data-nav="simulator"
|
|
||||||
[class.active]="activeSection() === 'simulator'"
|
|
||||||
(click)="setSection('simulator')"
|
|
||||||
>
|
|
||||||
{{ i18n.t().navSimulator }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
data-nav="terminal"
|
|
||||||
[class.active]="activeSection() === 'terminal'"
|
|
||||||
(click)="setSection('terminal')"
|
|
||||||
>
|
|
||||||
{{ i18n.t().navTerminal }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
data-nav="contact"
|
|
||||||
[class.active]="activeSection() === 'contact'"
|
|
||||||
(click)="setSection('contact')"
|
|
||||||
>
|
|
||||||
{{ i18n.t().navContact }}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="header-right-group">
|
<div class="header-right-group">
|
||||||
<div class="time-location-block">
|
<div class="time-location-block">
|
||||||
|
|
@ -81,119 +20,53 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Flechas Sutiles de Navegación Flotantes (Izquierda y Derecha debajo del Navbar) -->
|
<!-- Hero con Laptop 3D desarmable al scroll (300 frames) -->
|
||||||
<aside class="zen-floating-nav-controls" aria-label="Navegación entre secciones">
|
<main class="hero-container">
|
||||||
<button
|
<app-hero-3d></app-hero-3d>
|
||||||
type="button"
|
</main>
|
||||||
class="zen-nav-arrow-btn nav-arrow-left"
|
|
||||||
[class.disabled]="!hasPrevSection()"
|
|
||||||
[disabled]="!hasPrevSection()"
|
|
||||||
(click)="goToPrevSection()"
|
|
||||||
[attr.aria-label]="i18n.t().navPrevSection + ': ' + prevSectionName()"
|
|
||||||
[title]="hasPrevSection() ? i18n.t().navPrevSection + ' (' + prevSectionName() + ')' : ''"
|
|
||||||
>
|
|
||||||
<svg class="nav-arrow-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-arrow-label">{{ prevSectionName() }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="zen-nav-arrow-btn nav-arrow-right"
|
|
||||||
[class.disabled]="!hasNextSection()"
|
|
||||||
[disabled]="!hasNextSection()"
|
|
||||||
(click)="goToNextSection()"
|
|
||||||
[attr.aria-label]="i18n.t().navNextSection + ': ' + nextSectionName()"
|
|
||||||
[title]="hasNextSection() ? i18n.t().navNextSection + ' (' + nextSectionName() + ')' : ''"
|
|
||||||
>
|
|
||||||
<span class="nav-arrow-label">{{ nextSectionName() }}</span>
|
|
||||||
<svg class="nav-arrow-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Telón de Transición Suave con Animación de Logo Zen -->
|
|
||||||
<div
|
|
||||||
class="zen-transition-curtain"
|
|
||||||
[class.visible]="isCurtainVisible()"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div class="curtain-logo-wrapper">
|
|
||||||
<div class="logo-glow-ring"></div>
|
|
||||||
<div class="logo-image-container">
|
|
||||||
<img src="assets/images/logo.jpg" alt="Bryan Baño Logo" class="curtain-logo-img" />
|
|
||||||
<div class="logo-shimmer-sweep"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hero con Laptop 3D desarmable al scroll (240 frames) -->
|
|
||||||
@if (activeSection() === 'hero') {
|
|
||||||
<main class="hero-container isolated-view">
|
|
||||||
<app-hero-3d [initialPosition]="heroEntryPosition()"></app-hero-3d>
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Contenedor Base para Módulos Interactivos (50/50 Perfil Técnico y Retro Monitor) -->
|
<!-- Contenedor Base para Módulos Interactivos (50/50 Perfil Técnico y Retro Monitor) -->
|
||||||
@if (activeSection() === 'retro') {
|
<section class="interactive-stage">
|
||||||
<section class="interactive-stage isolated-view">
|
<div class="stage-grid">
|
||||||
<div class="stage-grid">
|
<!-- Slot 1: Perfil Técnico & Filosofía de Ingeniería -->
|
||||||
<!-- Slot 1: Perfil Técnico & Filosofía de Ingeniería -->
|
<article class="module-card profile-module-card">
|
||||||
<article class="module-card profile-module-card">
|
<app-technical-profile></app-technical-profile>
|
||||||
<app-technical-profile></app-technical-profile>
|
</article>
|
||||||
</article>
|
|
||||||
|
|
||||||
<!-- Slot 2: Retro OS en Monitor CRT Antiguo (50% Horizontal) -->
|
<!-- Slot 2: Retro OS en Monitor CRT Antiguo (50% Horizontal) -->
|
||||||
<article class="module-card retro-module-card">
|
<article class="module-card retro-module-card">
|
||||||
<app-retro-os></app-retro-os>
|
<app-retro-os></app-retro-os>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Sección de Proyectos Destacados (Casos Reales del CV a Ancho Completo) -->
|
<!-- Sección de Proyectos Destacados (Casos Reales del CV a Ancho Completo) -->
|
||||||
@if (activeSection() === 'projects') {
|
<section class="projects-stage">
|
||||||
<section class="projects-stage isolated-view">
|
<div class="projects-stage-inner">
|
||||||
<div class="projects-stage-inner">
|
<app-featured-projects></app-featured-projects>
|
||||||
<app-featured-projects></app-featured-projects>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Sección Simulador de Respuesta a Incidentes (Visualización de Arquitectura Cloud Security Serverless) -->
|
|
||||||
@if (activeSection() === 'simulator') {
|
|
||||||
<section class="simulator-stage isolated-view">
|
|
||||||
<app-incident-simulator></app-incident-simulator>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Sección Dedicada para la Terminal Interactiva Arch Linux / Fish Shell (Espaciosa y a Escala Completa) -->
|
<!-- Sección Dedicada para la Terminal Interactiva Arch Linux / Fish Shell (Espaciosa y a Escala Completa) -->
|
||||||
@if (activeSection() === 'terminal') {
|
<section class="terminal-stage">
|
||||||
<section class="terminal-stage isolated-view">
|
<div class="terminal-stage-inner">
|
||||||
<div class="terminal-stage-inner">
|
<div class="terminal-header-meta">
|
||||||
<!-- Cabecera Normalizada de Sección -->
|
<span class="mono-section-tag">{{ i18n.t().terminalWorkstationTag }}</span>
|
||||||
<header class="terminal-section-header">
|
<h2 class="terminal-section-heading">{{ i18n.t().terminalHeading }}</h2>
|
||||||
<div class="header-left">
|
<p class="terminal-section-desc">
|
||||||
<div class="title-with-badge">
|
{{ i18n.t().terminalSectionDesc }}
|
||||||
<span class="pulse-indicator" aria-hidden="true"></span>
|
</p>
|
||||||
<h2 class="section-title">{{ i18n.t().navTerminal }}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<app-terminal></app-terminal>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<app-terminal></app-terminal>
|
||||||
}
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Sección de Contacto Corporativa Premium (Full Width debajo de la Terminal) -->
|
<!-- Sección de Contacto Corporativa Premium (Full Width debajo de la Terminal) -->
|
||||||
@if (activeSection() === 'contact') {
|
<app-contact-section></app-contact-section>
|
||||||
<section class="contact-stage isolated-view">
|
|
||||||
<app-contact-section></app-contact-section>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Asistente Virtual Flotante Cyber-Zen (n8n + Gemini) -->
|
<!-- Footer Minimalista -->
|
||||||
<app-chat-bot></app-chat-bot>
|
<footer class="zen-footer">
|
||||||
|
<p class="footer-copy">© {{ currentYear() }} {{ i18n.t().footerCopy }}</p>
|
||||||
|
<span class="footer-version">v1.0.0-alpha</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,25 +1,12 @@
|
||||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, computed, inject, ViewChild, ElementRef } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, inject } from '@angular/core';
|
||||||
import { Hero3dComponent } from '../hero-3d/hero-3d.component';
|
import { Hero3dComponent } from '../hero-3d/hero-3d.component';
|
||||||
import { RetroOsComponent } from '../retro-os/retro-os.component';
|
import { RetroOsComponent } from '../retro-os/retro-os.component';
|
||||||
import { TerminalComponent } from '../terminal/terminal.component';
|
import { TerminalComponent } from '../terminal/terminal.component';
|
||||||
import { FeaturedProjectsComponent } from '../featured-projects/featured-projects/featured-projects.component';
|
import { FeaturedProjectsComponent } from '../featured-projects/featured-projects.component';
|
||||||
import { ContactSectionComponent } from '../contact-section/contact-section.component';
|
import { ContactSectionComponent } from '../contact-section/contact-section.component';
|
||||||
import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component';
|
import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component';
|
||||||
import { IncidentSimulatorComponent } from '../incident-simulator/incident-simulator.component';
|
|
||||||
import { ChatBotComponent } from '../chat-bot/chat-bot.component';
|
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
export type LandingSection = 'hero' | 'retro' | 'projects' | 'simulator' | 'terminal' | 'contact';
|
|
||||||
|
|
||||||
export const LANDING_SECTIONS: readonly LandingSection[] = [
|
|
||||||
'hero',
|
|
||||||
'retro',
|
|
||||||
'projects',
|
|
||||||
'simulator',
|
|
||||||
'terminal',
|
|
||||||
'contact'
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-landing',
|
selector: 'app-landing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -29,9 +16,7 @@ export const LANDING_SECTIONS: readonly LandingSection[] = [
|
||||||
TerminalComponent,
|
TerminalComponent,
|
||||||
FeaturedProjectsComponent,
|
FeaturedProjectsComponent,
|
||||||
ContactSectionComponent,
|
ContactSectionComponent,
|
||||||
TechnicalProfileComponent,
|
TechnicalProfileComponent
|
||||||
IncidentSimulatorComponent,
|
|
||||||
ChatBotComponent
|
|
||||||
],
|
],
|
||||||
templateUrl: './landing.component.html',
|
templateUrl: './landing.component.html',
|
||||||
styleUrl: './landing.component.css',
|
styleUrl: './landing.component.css',
|
||||||
|
|
@ -41,32 +26,6 @@ export class LandingComponent implements OnInit, OnDestroy {
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
protected readonly currentYear = signal(new Date().getFullYear());
|
protected readonly currentYear = signal(new Date().getFullYear());
|
||||||
protected readonly currentTime = signal<string>('');
|
protected readonly currentTime = signal<string>('');
|
||||||
protected readonly activeSection = signal<LandingSection>('hero');
|
|
||||||
protected readonly heroEntryPosition = signal<'top' | 'bottom'>('top');
|
|
||||||
protected readonly isCurtainVisible = signal<boolean>(false);
|
|
||||||
protected readonly transitionTargetSection = signal<LandingSection>('hero');
|
|
||||||
|
|
||||||
protected readonly hasPrevSection = computed(() => {
|
|
||||||
return LANDING_SECTIONS.indexOf(this.activeSection()) > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly hasNextSection = computed(() => {
|
|
||||||
return LANDING_SECTIONS.indexOf(this.activeSection()) < LANDING_SECTIONS.length - 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly prevSectionName = computed(() => {
|
|
||||||
const idx = LANDING_SECTIONS.indexOf(this.activeSection());
|
|
||||||
return idx > 0 ? this.getSectionTitle(LANDING_SECTIONS[idx - 1]) : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly nextSectionName = computed(() => {
|
|
||||||
const idx = LANDING_SECTIONS.indexOf(this.activeSection());
|
|
||||||
return idx < LANDING_SECTIONS.length - 1 ? this.getSectionTitle(LANDING_SECTIONS[idx + 1]) : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly transitionTargetTitle = computed(() => {
|
|
||||||
return this.getSectionTitle(this.transitionTargetSection());
|
|
||||||
});
|
|
||||||
|
|
||||||
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
|
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
|
@ -77,218 +36,9 @@ export class LandingComponent implements OnInit, OnDestroy {
|
||||||
hour12: false
|
hour12: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@ViewChild('navContainer') private navContainerRef?: ElementRef<HTMLElement>;
|
|
||||||
|
|
||||||
private isTransitioning = false;
|
|
||||||
private touchStartY = 0;
|
|
||||||
private touchStartX = 0;
|
|
||||||
private wheelDeltaAccum = 0;
|
|
||||||
private wheelResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.updateClock();
|
this.updateClock();
|
||||||
this.clockInterval = setInterval(() => this.updateClock(), 1000);
|
this.clockInterval = setInterval(() => this.updateClock(), 1000);
|
||||||
this.parseInitialHash();
|
|
||||||
this.setupScrollTransitions();
|
|
||||||
}
|
|
||||||
|
|
||||||
getSectionTitle(section: LandingSection): string {
|
|
||||||
const dict = this.i18n.t();
|
|
||||||
switch (section) {
|
|
||||||
case 'hero': return dict.navHome;
|
|
||||||
case 'retro': return dict.navRetro;
|
|
||||||
case 'projects': return dict.navProjects;
|
|
||||||
case 'simulator': return dict.navSimulator;
|
|
||||||
case 'terminal': return dict.navTerminal;
|
|
||||||
case 'contact': return dict.navContact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSection(section: LandingSection, entryPosition: 'top' | 'bottom' = 'top'): void {
|
|
||||||
if (this.activeSection() === section || this.isTransitioning) return;
|
|
||||||
this.isTransitioning = true;
|
|
||||||
this.transitionTargetSection.set(section);
|
|
||||||
this.isCurtainVisible.set(true);
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.documentElement.style.overflow = 'hidden';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.transitionTimer) clearTimeout(this.transitionTimer);
|
|
||||||
|
|
||||||
// Initial phase: show curtain and animated logo
|
|
||||||
this.transitionTimer = setTimeout(() => {
|
|
||||||
if (section === 'hero') {
|
|
||||||
this.heroEntryPosition.set(entryPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeSection.set(section);
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const hash = section === 'hero' ? '' : `#${section}`;
|
|
||||||
const url = window.location.pathname + window.location.search + hash;
|
|
||||||
window.history.replaceState(null, '', url);
|
|
||||||
this.scrollActiveNavIntoView(section);
|
|
||||||
|
|
||||||
if (entryPosition === 'bottom') {
|
|
||||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
||||||
window.scrollTo({ top: maxScroll, behavior: 'instant' });
|
|
||||||
} else {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Appreciate the animated logo for a full animation cycle before smoothly fading out
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isCurtainVisible.set(false);
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (entryPosition === 'bottom') {
|
|
||||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
||||||
window.scrollTo({ top: maxScroll, behavior: 'instant' });
|
|
||||||
} else {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isTransitioning = false;
|
|
||||||
}, 300);
|
|
||||||
}, 550);
|
|
||||||
}, 220);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupScrollTransitions(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
window.addEventListener('wheel', this.onWheel, { passive: false });
|
|
||||||
window.addEventListener('touchstart', this.onTouchStart, { passive: true });
|
|
||||||
window.addEventListener('touchend', this.onTouchEnd, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private onWheel = (e: WheelEvent): void => {
|
|
||||||
if (this.isTransitioning || this.isCurtainVisible()) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignorar si el usuario está interactuando con un modal o scroll interno
|
|
||||||
const target = e.target as HTMLElement | null;
|
|
||||||
if (target?.closest('.project-detail-overlay, .modal-scroll-area')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollY = window.scrollY || window.pageYOffset;
|
|
||||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
||||||
const isAtTop = scrollY <= 6;
|
|
||||||
const isAtBottom = scrollY >= maxScroll - 6;
|
|
||||||
|
|
||||||
if (e.deltaY > 0 && isAtBottom) {
|
|
||||||
this.wheelDeltaAccum += e.deltaY;
|
|
||||||
if (this.wheelDeltaAccum > 50) {
|
|
||||||
this.wheelDeltaAccum = 0;
|
|
||||||
this.goToNextSection();
|
|
||||||
}
|
|
||||||
} else if (e.deltaY < 0 && isAtTop) {
|
|
||||||
this.wheelDeltaAccum += e.deltaY;
|
|
||||||
if (this.wheelDeltaAccum < -50) {
|
|
||||||
this.wheelDeltaAccum = 0;
|
|
||||||
this.goToPrevSection();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.wheelDeltaAccum = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.wheelResetTimer) clearTimeout(this.wheelResetTimer);
|
|
||||||
this.wheelResetTimer = setTimeout(() => {
|
|
||||||
this.wheelDeltaAccum = 0;
|
|
||||||
}, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onTouchStart = (e: TouchEvent): void => {
|
|
||||||
if (this.isTransitioning || this.isCurtainVisible()) return;
|
|
||||||
if (e.touches.length > 0) {
|
|
||||||
this.touchStartY = e.touches[0].clientY;
|
|
||||||
this.touchStartX = e.touches[0].clientX;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onTouchEnd = (e: TouchEvent): void => {
|
|
||||||
if (this.isTransitioning || this.isCurtainVisible() || e.changedTouches.length === 0) return;
|
|
||||||
|
|
||||||
const target = e.target as HTMLElement | null;
|
|
||||||
if (target?.closest('.project-detail-overlay, .modal-scroll-area')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchEndY = e.changedTouches[0].clientY;
|
|
||||||
const touchEndX = e.changedTouches[0].clientX;
|
|
||||||
const diffY = this.touchStartY - touchEndY;
|
|
||||||
const diffX = this.touchStartX - touchEndX;
|
|
||||||
|
|
||||||
if (Math.abs(diffY) > 50 && Math.abs(diffY) > Math.abs(diffX) * 1.3) {
|
|
||||||
const scrollY = window.scrollY || window.pageYOffset;
|
|
||||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
||||||
const isAtTop = scrollY <= 10;
|
|
||||||
const isAtBottom = scrollY >= maxScroll - 10;
|
|
||||||
|
|
||||||
if (diffY > 0 && isAtBottom) {
|
|
||||||
this.goToNextSection();
|
|
||||||
} else if (diffY < 0 && isAtTop) {
|
|
||||||
this.goToPrevSection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected goToNextSection(): void {
|
|
||||||
const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
|
|
||||||
if (currentIndex >= 0 && currentIndex < LANDING_SECTIONS.length - 1) {
|
|
||||||
const next = LANDING_SECTIONS[currentIndex + 1];
|
|
||||||
this.setSection(next, 'top');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected goToPrevSection(): void {
|
|
||||||
const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
const prev = LANDING_SECTIONS[currentIndex - 1];
|
|
||||||
this.setSection(prev, 'bottom');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollActiveNavIntoView(section: LandingSection): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!this.navContainerRef) return;
|
|
||||||
const activeEl = this.navContainerRef.nativeElement.querySelector(`[data-nav="${section}"]`);
|
|
||||||
if (activeEl) {
|
|
||||||
activeEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}, 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseInitialHash(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const hash = window.location.hash.toLowerCase().replace('#', '');
|
|
||||||
let targetSection: LandingSection = 'hero';
|
|
||||||
if (hash === 'retro' || hash === 'desktop') {
|
|
||||||
targetSection = 'retro';
|
|
||||||
} else if (hash === 'projects' || hash === 'proyectos') {
|
|
||||||
targetSection = 'projects';
|
|
||||||
} else if (hash === 'simulator' || hash === 'simulador') {
|
|
||||||
targetSection = 'simulator';
|
|
||||||
} else if (hash === 'terminal') {
|
|
||||||
targetSection = 'terminal';
|
|
||||||
} else if (hash === 'contact' || hash === 'contacto') {
|
|
||||||
targetSection = 'contact';
|
|
||||||
}
|
|
||||||
this.activeSection.set(targetSection);
|
|
||||||
this.scrollActiveNavIntoView(targetSection);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateClock(): void {
|
private updateClock(): void {
|
||||||
|
|
@ -299,20 +49,5 @@ export class LandingComponent implements OnInit, OnDestroy {
|
||||||
if (this.clockInterval !== null) {
|
if (this.clockInterval !== null) {
|
||||||
clearInterval(this.clockInterval);
|
clearInterval(this.clockInterval);
|
||||||
}
|
}
|
||||||
if (this.wheelResetTimer !== null) {
|
|
||||||
clearTimeout(this.wheelResetTimer);
|
|
||||||
}
|
|
||||||
if (this.transitionTimer !== null) {
|
|
||||||
clearTimeout(this.transitionTimer);
|
|
||||||
}
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.removeEventListener('wheel', this.onWheel);
|
|
||||||
window.removeEventListener('touchstart', this.onTouchStart);
|
|
||||||
window.removeEventListener('touchend', this.onTouchEnd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,40 +8,8 @@
|
||||||
>
|
>
|
||||||
<div class="xp-icon-graphic">
|
<div class="xp-icon-graphic">
|
||||||
@switch (icon.iconType) {
|
@switch (icon.iconType) {
|
||||||
@case ('minesweeper') {
|
|
||||||
<!-- Icono clásico de Buscaminas Windows (Mina naval con pinchos sobre relieve 3D) -->
|
|
||||||
<svg class="xp-icon-svg" viewBox="0 0 36 36" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="mineTileGrad" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#dfdfdf" />
|
|
||||||
<stop offset="100%" stop-color="#b0b0b0" />
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="mineBodyGrad" cx="35%" cy="35%" r="65%">
|
|
||||||
<stop offset="0%" stop-color="#555555" />
|
|
||||||
<stop offset="50%" stop-color="#1c1c1c" />
|
|
||||||
<stop offset="100%" stop-color="#000000" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<ellipse cx="18" cy="31" rx="12" ry="3.5" fill="#1b2a32" opacity="0.45" />
|
|
||||||
<!-- Baldosa clásica con bisel 3D -->
|
|
||||||
<rect x="4" y="4" width="28" height="28" rx="2" fill="url(#mineTileGrad)" stroke="#808080" stroke-width="1" />
|
|
||||||
<path d="M4 31V5H31" stroke="#ffffff" stroke-width="1.8" />
|
|
||||||
<path d="M5 31H31V5" stroke="#707070" stroke-width="1.8" />
|
|
||||||
<!-- Pinchos de la mina naval -->
|
|
||||||
<line x1="18" y1="8" x2="18" y2="28" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
|
||||||
<line x1="8" y1="18" x2="28" y2="18" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
|
||||||
<line x1="10.9" y1="10.9" x2="25.1" y2="25.1" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
|
||||||
<line x1="10.9" y1="25.1" x2="25.1" y2="10.9" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
|
||||||
<!-- Esfera central de la mina -->
|
|
||||||
<circle cx="18" cy="18" r="7.5" fill="url(#mineBodyGrad)" />
|
|
||||||
<!-- Brillo especular clásico -->
|
|
||||||
<rect x="14.5" y="14.5" width="2.8" height="2.8" fill="#ffffff" rx="0.5" />
|
|
||||||
<!-- Destello rojo / mecha -->
|
|
||||||
<circle cx="23" cy="13" r="1.5" fill="#ff3333" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
@case ('gamepad') {
|
@case ('gamepad') {
|
||||||
<!-- Icono Joystick 3D fallback -->
|
<!-- Icono característico Juegos Windows XP (Pinball / Joystick 3D) -->
|
||||||
<svg class="xp-icon-svg" viewBox="0 0 36 36" fill="none">
|
<svg class="xp-icon-svg" viewBox="0 0 36 36" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="xpJoyGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="xpJoyGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
|
|
||||||
|
|
@ -1,417 +1,139 @@
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #c0c0c0;
|
|
||||||
color: #000000;
|
|
||||||
user-select: none;
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
padding: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-minesweeper-window {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Marco exterior con relieve 3D sobresaliente (Outset) */
|
|
||||||
.xp-minesweeper-frame {
|
|
||||||
background: #c0c0c0;
|
|
||||||
border-top: 3px solid #ffffff;
|
|
||||||
border-left: 3px solid #ffffff;
|
|
||||||
border-right: 3px solid #7b7b7b;
|
|
||||||
border-bottom: 3px solid #7b7b7b;
|
|
||||||
padding: 6px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HUD Superior con relieve 3D hundido (Inset) */
|
|
||||||
.xp-ms-hud {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: #c0c0c0;
|
|
||||||
border-top: 2px solid #7b7b7b;
|
|
||||||
border-left: 2px solid #7b7b7b;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contadores LED Digitales (7 Segmentos) */
|
|
||||||
.xp-led-display {
|
|
||||||
position: relative;
|
|
||||||
background: #000000;
|
|
||||||
border-top: 1px solid #7b7b7b;
|
|
||||||
border-left: 1px solid #7b7b7b;
|
|
||||||
border-right: 1px solid #ffffff;
|
|
||||||
border-bottom: 1px solid #ffffff;
|
|
||||||
padding: 1px 4px;
|
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
min-width: 44px;
|
|
||||||
text-align: right;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-ghost {
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
right: 4px;
|
|
||||||
color: #380000;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-digits {
|
|
||||||
position: relative;
|
|
||||||
color: #ff0000;
|
|
||||||
text-shadow: 0 0 2px rgba(255, 0, 0, 0.8);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Botón Cara Sonriente Clásica */
|
|
||||||
.xp-smiley-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: #c0c0c0;
|
|
||||||
border-top: 2px solid #ffffff;
|
|
||||||
border-left: 2px solid #ffffff;
|
|
||||||
border-right: 2px solid #7b7b7b;
|
|
||||||
border-bottom: 2px solid #7b7b7b;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-smiley-btn:active {
|
|
||||||
border-top: 2px solid #7b7b7b;
|
|
||||||
border-left: 2px solid #7b7b7b;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.face-icon {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablero de Juego con relieve hundido (Inset) */
|
|
||||||
.xp-ms-board-inset {
|
|
||||||
border-top: 3px solid #7b7b7b;
|
|
||||||
border-left: 3px solid #7b7b7b;
|
|
||||||
border-right: 3px solid #ffffff;
|
|
||||||
border-bottom: 3px solid #ffffff;
|
|
||||||
background: #c0c0c0;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-ms-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-ms-row {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Casilla Individual de Buscaminas */
|
|
||||||
.xp-ms-cell {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: #c0c0c0;
|
|
||||||
border-top: 2px solid #ffffff;
|
|
||||||
border-left: 2px solid #ffffff;
|
|
||||||
border-right: 2px solid #7b7b7b;
|
|
||||||
border-bottom: 2px solid #7b7b7b;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: 'Courier New', Tahoma, sans-serif;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-ms-cell:active:not(.revealed):not(.flagged) {
|
|
||||||
border: 1px solid #7b7b7b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Casilla Descubierta */
|
|
||||||
.xp-ms-cell.revealed {
|
|
||||||
border: 1px solid #7b7b7b;
|
|
||||||
background: #c0c0c0;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mina explotada */
|
|
||||||
.xp-ms-cell.exploded {
|
|
||||||
background: #ff0000 !important;
|
|
||||||
border: 1px solid #7b7b7b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Iconos de celda */
|
|
||||||
.cell-icon {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-icon.flag {
|
|
||||||
filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-icon.false-flag {
|
|
||||||
color: #ff0000;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-num {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Paleta de Colores de Números Clásica de Buscaminas */
|
|
||||||
.num-1 .cell-num { color: #0000ff; } /* 1: Azul */
|
|
||||||
.num-2 .cell-num { color: #008000; } /* 2: Verde */
|
|
||||||
.num-3 .cell-num { color: #ff0000; } /* 3: Rojo */
|
|
||||||
.num-4 .cell-num { color: #000080; } /* 4: Azul marino */
|
|
||||||
.num-5 .cell-num { color: #800000; } /* 5: Granate */
|
|
||||||
.num-6 .cell-num { color: #008080; } /* 6: Verde azulado / Cyan oscuro */
|
|
||||||
.num-7 .cell-num { color: #000000; } /* 7: Negro */
|
|
||||||
.num-8 .cell-num { color: #808080; } /* 8: Gris */
|
|
||||||
|
|
||||||
/* Barra de Estado Clásica Inferior (Windows 95/XP Style) */
|
|
||||||
.xp-ms-statusbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 236px;
|
height: 100%;
|
||||||
background: #c0c0c0;
|
|
||||||
border-top: 2px solid #7b7b7b;
|
|
||||||
border-left: 2px solid #7b7b7b;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
padding: 1px 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-status-mode-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
background: #c0c0c0;
|
|
||||||
border-top: 1px solid #ffffff;
|
|
||||||
border-left: 1px solid #ffffff;
|
|
||||||
border-right: 1px solid #7b7b7b;
|
|
||||||
border-bottom: 1px solid #7b7b7b;
|
|
||||||
padding: 1px 6px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #000000;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-status-mode-btn:active {
|
|
||||||
border-top: 1px solid #7b7b7b;
|
|
||||||
border-left: 1px solid #7b7b7b;
|
|
||||||
border-right: 1px solid #ffffff;
|
|
||||||
border-bottom: 1px solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-icon {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-text {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-status-record {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Cuadro de Diálogo Clásico Windows Win32 (Récord / Victoria)
|
|
||||||
========================================================================== */
|
|
||||||
.xp-dialog-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-window {
|
|
||||||
width: 220px;
|
|
||||||
background: #ece9d8;
|
background: #ece9d8;
|
||||||
border-top: 2px solid #ffffff;
|
user-select: none;
|
||||||
border-left: 2px solid #ffffff;
|
font-family: var(--font-sans);
|
||||||
border-right: 2px solid #555555;
|
|
||||||
border-bottom: 2px solid #555555;
|
|
||||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.6);
|
|
||||||
font-family: 'Tahoma', 'Segoe UI', Geneva, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Barra de título azul clásica de Windows XP */
|
.xp-game-wrapper {
|
||||||
.xp-dialog-titlebar {
|
display: flex;
|
||||||
background: linear-gradient(90deg, #0055ea 0%, #3f8cf3 100%);
|
flex-direction: column;
|
||||||
color: #ffffff;
|
align-items: center;
|
||||||
font-size: 0.6875rem;
|
padding: 6px;
|
||||||
font-weight: 700;
|
gap: 6px;
|
||||||
padding: 2px 4px;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barra Superior de Estadísticas y Acciones */
|
||||||
|
.xp-game-header {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
background: #f0ede0;
|
||||||
|
border: 1px solid #7f9db9;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xp-dialog-btn-close {
|
.xp-hud-stats { display: flex; gap: 10px; }
|
||||||
width: 14px;
|
.hud-item { display: flex; align-items: center; gap: 4px; }
|
||||||
height: 14px;
|
.hud-tag { font-size: 0.625rem; font-weight: 700; color: #333333; }
|
||||||
background: #d13a17;
|
.hud-num { font-size: 0.8125rem; font-weight: 700; color: #0055ea; }
|
||||||
color: #ffffff;
|
.hud-num.highlight { color: #d83a17; }
|
||||||
border: 1px solid #ffffff;
|
.xp-hud-actions { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
/* Botones Clásicos Windows XP con borde naranja al hacer hover */
|
||||||
|
.xp-classic-btn {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #ece9d8 100%);
|
||||||
|
border: 1px solid #7f9db9;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #000000;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-classic-btn:hover:not(:disabled) {
|
||||||
|
border-color: #f29536;
|
||||||
|
background: linear-gradient(180deg, #fff7ea 0%, #ffdca8 100%);
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff, 0 0 4px rgba(242, 149, 54, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-classic-btn:active:not(:disabled) { background: #d8d4c2; transform: translateY(1px); }
|
||||||
|
.xp-classic-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Marco del Tablero Canvas */
|
||||||
|
.xp-canvas-box {
|
||||||
|
position: relative;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border: 2px inset #7f9db9;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-size: 0.5625rem;
|
background: #1c2833;
|
||||||
line-height: 1;
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-snake-canvas { width: 300px; height: 300px; display: block; }
|
||||||
|
|
||||||
|
/* Banner de Instrucción en la parte inferior del canvas */
|
||||||
|
.xp-start-banner {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.78);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card de Game Over */
|
||||||
|
.xp-gameover-card {
|
||||||
|
position: absolute;
|
||||||
|
inset: 20px;
|
||||||
|
background: rgba(236, 233, 216, 0.96);
|
||||||
|
border: 2px solid #0055ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameover-text { font-size: 1rem; font-weight: 700; color: #d83a17; }
|
||||||
|
.gameover-pts { font-size: 0.8125rem; font-weight: 600; color: #000000; }
|
||||||
|
.xp-save-row { display: flex; gap: 4px; }
|
||||||
|
.xp-input-tag { width: 50px; text-align: center; font-weight: 700; border: 1px solid #7f9db9; font-size: 0.8125rem; text-transform: uppercase; }
|
||||||
|
.xp-saved-label { font-size: 0.6875rem; color: #2e7d32; font-weight: 600; }
|
||||||
|
.btn-retry { padding: 4px 12px; }
|
||||||
|
|
||||||
|
/* Controles Direccionales D-Pad */
|
||||||
|
.xp-dpad-container { width: 100%; max-width: 300px; display: flex; justify-content: center; }
|
||||||
|
.xp-dpad-grid { display: grid; grid-template-columns: repeat(3, 36px); grid-template-rows: repeat(2, 30px); gap: 4px; }
|
||||||
|
.xp-arrow-btn {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #ece9d8 100%);
|
||||||
|
border: 1px solid #7f9db9;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #0055ea;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
.xp-arrow-btn:hover { border-color: #f29536; background: linear-gradient(180deg, #fff7ea 0%, #ffdca8 100%); }
|
||||||
.xp-dialog-body {
|
.xp-arrow-btn:active { background: #d8d4c2; transform: translateY(1px); }
|
||||||
padding: 8px;
|
.xp-arrow-btn.up { grid-column: 2; grid-row: 1; }
|
||||||
display: flex;
|
.xp-arrow-btn.left { grid-column: 1; grid-row: 2; }
|
||||||
flex-direction: column;
|
.xp-arrow-btn.down { grid-column: 2; grid-row: 2; }
|
||||||
gap: 8px;
|
.xp-arrow-btn.right { grid-column: 3; grid-row: 2; }
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-content-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-trophy {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-message-box {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-main-msg {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-sub-msg {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-input-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-textbox {
|
|
||||||
width: 50px;
|
|
||||||
background: #ffffff;
|
|
||||||
border-top: 2px solid #7b7b7b;
|
|
||||||
border-left: 2px solid #7b7b7b;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
padding: 1px 4px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-align: center;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Botonera clásica */
|
|
||||||
.xp-dialog-button-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-button {
|
|
||||||
background: #ece9d8;
|
|
||||||
border-top: 2px solid #ffffff;
|
|
||||||
border-left: 2px solid #ffffff;
|
|
||||||
border-right: 2px solid #7b7b7b;
|
|
||||||
border-bottom: 2px solid #7b7b7b;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-button.default-btn {
|
|
||||||
border: 2px solid #000000;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-dialog-button:active {
|
|
||||||
border-top: 2px solid #7b7b7b;
|
|
||||||
border-left: 2px solid #7b7b7b;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,153 +1,89 @@
|
||||||
<div class="xp-minesweeper-window" (contextmenu)="$event.preventDefault()">
|
<div class="xp-game-wrapper">
|
||||||
<!-- Marco Exterior Biselado Clásico (Windows 95/XP Style) -->
|
<!-- Barra Superior Clásica de Juegos de Windows XP -->
|
||||||
<div class="xp-minesweeper-frame">
|
<header class="xp-game-header">
|
||||||
<!-- Barra Superior de HUD (Contador Minas, Cara Sonriente, Cronómetro) -->
|
<div class="xp-hud-stats">
|
||||||
<header class="xp-ms-hud">
|
<div class="hud-item">
|
||||||
<!-- Pantalla LED 7-Segmentos: Minas Restantes -->
|
<span class="hud-tag">{{ i18n.t().retroGameScore }}</span>
|
||||||
<div class="xp-led-display" title="Minas restantes">
|
<span class="hud-num">{{ score() }}</span>
|
||||||
<span class="led-ghost">888</span>
|
|
||||||
<span class="led-digits">{{ formattedMines() }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hud-item">
|
||||||
|
<span class="hud-tag">{{ i18n.t().retroGameHigh }}</span>
|
||||||
|
<span class="hud-num highlight">{{ highScore() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Botón Cara Sonriente Clásica -->
|
<div class="xp-hud-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="xp-smiley-btn"
|
class="xp-classic-btn"
|
||||||
(click)="onFaceClick()"
|
(click)="startGame()"
|
||||||
(mousedown)="onFaceMouseDown()"
|
title="Iniciar o reiniciar la partida"
|
||||||
(mouseup)="onFaceMouseUp()"
|
|
||||||
title="Reiniciar partida"
|
|
||||||
[attr.aria-label]="'Reiniciar Buscaminas'"
|
|
||||||
>
|
>
|
||||||
@switch (face()) {
|
{{ isPlaying() ? i18n.t().retroGameRestart : i18n.t().retroGameStart }}
|
||||||
@case ('dead') {
|
</button>
|
||||||
<span class="face-icon">😵</span>
|
<button
|
||||||
}
|
type="button"
|
||||||
@case ('won') {
|
class="xp-classic-btn"
|
||||||
<span class="face-icon">😎</span>
|
[disabled]="!isPlaying() || isGameOver()"
|
||||||
}
|
(click)="togglePause()"
|
||||||
@case ('pressed') {
|
title="Pausar el juego"
|
||||||
<span class="face-icon">😮</span>
|
>
|
||||||
}
|
{{ isPaused() ? i18n.t().retroGameResume : i18n.t().retroGamePause }}
|
||||||
@default {
|
|
||||||
<span class="face-icon">🙂</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Pantalla LED 7-Segmentos: Tiempo Transcurrido -->
|
|
||||||
<div class="xp-led-display" title="Tiempo transcurrido (segundos)">
|
|
||||||
<span class="led-ghost">888</span>
|
|
||||||
<span class="led-digits">{{ formattedTime() }}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Tablero Cuadrícula 9x9 con Bisel Inset -->
|
|
||||||
<div class="xp-ms-board-inset">
|
|
||||||
<div class="xp-ms-grid">
|
|
||||||
@for (row of grid(); track $index; let r = $index) {
|
|
||||||
<div class="xp-ms-row">
|
|
||||||
@for (cell of row; track cell.col; let c = $index) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="xp-ms-cell"
|
|
||||||
[class.revealed]="cell.isRevealed"
|
|
||||||
[class.exploded]="cell.isExploded"
|
|
||||||
[class.flagged]="cell.isFlagged"
|
|
||||||
[class.mine]="cell.isMine && cell.isRevealed"
|
|
||||||
[class.num-1]="cell.isRevealed && cell.adjacentMines === 1"
|
|
||||||
[class.num-2]="cell.isRevealed && cell.adjacentMines === 2"
|
|
||||||
[class.num-3]="cell.isRevealed && cell.adjacentMines === 3"
|
|
||||||
[class.num-4]="cell.isRevealed && cell.adjacentMines === 4"
|
|
||||||
[class.num-5]="cell.isRevealed && cell.adjacentMines === 5"
|
|
||||||
[class.num-6]="cell.isRevealed && cell.adjacentMines === 6"
|
|
||||||
[class.num-7]="cell.isRevealed && cell.adjacentMines === 7"
|
|
||||||
[class.num-8]="cell.isRevealed && cell.adjacentMines === 8"
|
|
||||||
(click)="onCellClick(r, c)"
|
|
||||||
(contextmenu)="onCellRightClick($event, r, c)"
|
|
||||||
(mousedown)="onCellMouseDown(cell)"
|
|
||||||
(dblclick)="onCellDblClick(r, c)"
|
|
||||||
[attr.aria-label]="'Fila ' + (r + 1) + ', Columna ' + (c + 1)"
|
|
||||||
>
|
|
||||||
@if (cell.isFlagged && !(isGameOver() && !cell.isMine)) {
|
|
||||||
<span class="cell-icon flag">🚩</span>
|
|
||||||
} @else if (isGameOver() && !cell.isMine && cell.isFlagged) {
|
|
||||||
<span class="cell-icon false-flag">❌</span>
|
|
||||||
} @else if (cell.isRevealed && cell.isMine) {
|
|
||||||
<span class="cell-icon bomb">💣</span>
|
|
||||||
} @else if (cell.isRevealed && cell.adjacentMines > 0) {
|
|
||||||
<span class="cell-num">{{ cell.adjacentMines }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- Barra de Estado Clásica Inferior (Windows 95/XP Style) -->
|
<!-- Contenedor del Tablero Canvas (Siempre visible, interactivo al clic) -->
|
||||||
<footer class="xp-ms-statusbar">
|
<div class="xp-canvas-box" (click)="onCanvasClick()" title="Haz clic aquí o pulsa cualquier flecha para jugar">
|
||||||
<button
|
<canvas #gameCanvas width="300" height="300" class="xp-snake-canvas"></canvas>
|
||||||
type="button"
|
|
||||||
class="xp-status-mode-btn"
|
<!-- Banner sutil de instrucciones (sin tapar a la serpiente) -->
|
||||||
(click)="toggleFlagMode()"
|
@if (!isPlaying() && !isGameOver()) {
|
||||||
[title]="isFlagMode() ? 'Modo actual: Banderas (clic para Cavar)' : 'Modo actual: Cavar (clic para Banderas)'"
|
<div class="xp-start-banner">
|
||||||
>
|
<span>{{ i18n.t().retroGameBanner }}</span>
|
||||||
<span class="mode-icon">{{ isFlagMode() ? '🚩' : '⛏️' }}</span>
|
|
||||||
<span class="mode-text">{{ isFlagMode() ? i18n.t().retroGameFlagMode : i18n.t().retroGameDigMode }}</span>
|
|
||||||
</button>
|
|
||||||
@if (bestTime() !== null) {
|
|
||||||
<div class="xp-status-record" title="Mejor récord registrado">
|
|
||||||
<span>★ {{ bestTime() }}s</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Cuadro de Diálogo Clásico Modal de Récord / Victoria (Estilo Windows Win32) -->
|
<!-- Banner de Game Over -->
|
||||||
@if (showWinDialog()) {
|
@if (isGameOver()) {
|
||||||
<div class="xp-dialog-overlay" (click)="closeWinDialog()">
|
<div class="xp-gameover-card" (click)="$event.stopPropagation()">
|
||||||
<div class="xp-dialog-window" (click)="$event.stopPropagation()">
|
<span class="gameover-text">{{ i18n.t().retroGameOver }}</span>
|
||||||
<!-- Barra de título clásica del diálogo -->
|
<span class="gameover-pts">{{ i18n.t().retroGameScoreLabel }} {{ score() }}</span>
|
||||||
<header class="xp-dialog-titlebar">
|
|
||||||
<span class="xp-dialog-title">Buscaminas</span>
|
|
||||||
<button type="button" class="xp-dialog-btn-close" (click)="closeWinDialog()" aria-label="Cerrar">✕</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Cuerpo del diálogo -->
|
@if (!isScoreSaved() && score() > 0) {
|
||||||
<div class="xp-dialog-body">
|
<div class="xp-save-row">
|
||||||
<div class="xp-dialog-content-row">
|
<input
|
||||||
<span class="xp-dialog-trophy">🏆</span>
|
type="text"
|
||||||
<div class="xp-dialog-message-box">
|
maxlength="4"
|
||||||
<p class="xp-dialog-main-msg">¡Enhorabuena!</p>
|
[value]="playerTag()"
|
||||||
<p class="xp-dialog-sub-msg">Has despejado el campo en <strong>{{ elapsedSeconds() }}</strong> segundos.</p>
|
(input)="playerTag.set($any($event.target).value.toUpperCase())"
|
||||||
<div class="xp-dialog-input-group">
|
class="xp-input-tag"
|
||||||
<label for="xpPlayerName" class="xp-dialog-label">Nombre:</label>
|
placeholder="TAG"
|
||||||
<input
|
/>
|
||||||
id="xpPlayerName"
|
<button type="button" class="xp-classic-btn btn-save" (click)="submitScore()">
|
||||||
type="text"
|
{{ i18n.t().retroGameSave }}
|
||||||
maxlength="4"
|
|
||||||
[value]="playerTag()"
|
|
||||||
(input)="playerTag.set($any($event.target).value.toUpperCase())"
|
|
||||||
class="xp-dialog-textbox"
|
|
||||||
placeholder="XP"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botonera clásica Windows -->
|
|
||||||
<div class="xp-dialog-button-row">
|
|
||||||
<button type="button" class="xp-dialog-button default-btn" (click)="submitScoreAndClose()">
|
|
||||||
Aceptar
|
|
||||||
</button>
|
|
||||||
<button type="button" class="xp-dialog-button" (click)="closeWinDialog()">
|
|
||||||
Cancelar
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
@if (isScoreSaved()) {
|
||||||
|
<span class="xp-saved-label">{{ i18n.t().retroGameSaved }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="button" class="xp-classic-btn btn-retry" (click)="startGame()">
|
||||||
|
{{ i18n.t().retroGamePlayAgain }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controles Direccionales Táctiles / Ratón (D-Pad) -->
|
||||||
|
<footer class="xp-dpad-container" (click)="$event.stopPropagation()">
|
||||||
|
<div class="xp-dpad-grid">
|
||||||
|
<button type="button" class="xp-arrow-btn up" (click)="setDirection('UP')" [attr.aria-label]="i18n.t().retroGameUp">▲</button>
|
||||||
|
<button type="button" class="xp-arrow-btn left" (click)="setDirection('LEFT')" [attr.aria-label]="i18n.t().retroGameLeft">◀</button>
|
||||||
|
<button type="button" class="xp-arrow-btn down" (click)="setDirection('DOWN')" [attr.aria-label]="i18n.t().retroGameDown">▼</button>
|
||||||
|
<button type="button" class="xp-arrow-btn right" (click)="setDirection('RIGHT')" [attr.aria-label]="i18n.t().retroGameRight">▶</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
AfterViewInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
viewChild,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
signal,
|
signal,
|
||||||
computed,
|
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RetroAudioService } from '../../../../../application/services/retro-audio.service';
|
import { RetroAudioService } from '../../../../../application/services/retro-audio.service';
|
||||||
import { RetroScorePort } from '../../../../../domain/ports/retro-score.port';
|
import { RetroScorePort } from '../../../../../domain/ports/retro-score.port';
|
||||||
import { MinesweeperCell, MinesweeperFace } from '../../../../../domain/models/retro-game.model';
|
import { Direction, Point } from '../../../../../domain/models/retro-game.model';
|
||||||
import { I18nService } from '../../../../../application/services/i18n.service';
|
import { I18nService } from '../../../../../application/services/i18n.service';
|
||||||
|
|
||||||
const ROWS = 9;
|
const GRID_SIZE = 15;
|
||||||
const COLS = 9;
|
const CELL_SIZE = 20; // 15 * 20 = 300px
|
||||||
const TOTAL_MINES = 10;
|
const TICK_RATE_MS = 110;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-retro-game-canvas',
|
selector: 'app-retro-game-canvas',
|
||||||
|
|
@ -24,395 +25,332 @@ const TOTAL_MINES = 10;
|
||||||
styleUrl: './retro-game-canvas.component.css',
|
styleUrl: './retro-game-canvas.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class RetroGameCanvasComponent implements OnInit, OnDestroy {
|
export class RetroGameCanvasComponent implements AfterViewInit, OnDestroy {
|
||||||
private readonly audio = inject(RetroAudioService);
|
private readonly audio = inject(RetroAudioService);
|
||||||
private readonly scorePort = inject(RetroScorePort);
|
private readonly scorePort = inject(RetroScorePort);
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
// Estados del juego con Angular Signals
|
protected readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('gameCanvas');
|
||||||
protected readonly grid = signal<MinesweeperCell[][]>([]);
|
private ctx: CanvasRenderingContext2D | null = null;
|
||||||
protected readonly isPlaying = signal<boolean>(false);
|
|
||||||
|
protected readonly score = signal<number>(0);
|
||||||
|
protected readonly highScore = signal<number>(0);
|
||||||
protected readonly isGameOver = signal<boolean>(false);
|
protected readonly isGameOver = signal<boolean>(false);
|
||||||
protected readonly isWon = signal<boolean>(false);
|
protected readonly isPaused = signal<boolean>(false);
|
||||||
protected readonly face = signal<MinesweeperFace>('idle');
|
protected readonly isPlaying = signal<boolean>(false);
|
||||||
protected readonly elapsedSeconds = signal<number>(0);
|
|
||||||
protected readonly bestTime = signal<number | null>(null);
|
|
||||||
protected readonly isFlagMode = signal<boolean>(false); // Para dispositivos táctiles
|
|
||||||
protected readonly playerTag = signal<string>('XP');
|
protected readonly playerTag = signal<string>('XP');
|
||||||
protected readonly isScoreSaved = signal<boolean>(false);
|
protected readonly isScoreSaved = signal<boolean>(false);
|
||||||
protected readonly showWinDialog = signal<boolean>(false);
|
|
||||||
|
|
||||||
private timerInterval: ReturnType<typeof setInterval> | null = null;
|
private snake: Point[] = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }];
|
||||||
private minesPlaced = false;
|
private direction: Direction = 'UP';
|
||||||
|
private nextDirection: Direction = 'UP';
|
||||||
|
private food: Point = { x: 7, y: 3 };
|
||||||
|
|
||||||
// Contador de minas restantes (Minas totales - banderas colocadas)
|
private animationFrameId: number | null = null;
|
||||||
protected readonly remainingMines = computed<number>(() => {
|
private lastTickTime = 0;
|
||||||
let flags = 0;
|
|
||||||
const currentGrid = this.grid();
|
ngAfterViewInit(): void {
|
||||||
for (let r = 0; r < ROWS; r++) {
|
const canvas = this.canvasRef()?.nativeElement;
|
||||||
for (let c = 0; c < COLS; c++) {
|
if (!canvas) return;
|
||||||
if (currentGrid[r]?.[c]?.isFlagged) {
|
this.ctx = canvas.getContext('2d');
|
||||||
flags++;
|
if (this.ctx) {
|
||||||
}
|
this.ctx.imageSmoothingEnabled = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return TOTAL_MINES - flags;
|
this.loadHighScore();
|
||||||
});
|
// Dibujar inmediatamente el tablero con la serpiente y la manzana 100% visibles
|
||||||
|
this.resetState();
|
||||||
// Contador de minas en formato clásico 3 dígitos LED (ej: "010", "009", "-01")
|
this.render();
|
||||||
protected readonly formattedMines = computed<string>(() => {
|
|
||||||
const rem = this.remainingMines();
|
|
||||||
if (rem < 0) {
|
|
||||||
return `-${Math.abs(rem).toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
return Math.min(rem, 999).toString().padStart(3, '0');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Contador de tiempo en formato 3 dígitos LED (ej: "000", "042", "999")
|
|
||||||
protected readonly formattedTime = computed<string>(() => {
|
|
||||||
const sec = this.elapsedSeconds();
|
|
||||||
return Math.min(sec, 999).toString().padStart(3, '0');
|
|
||||||
});
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadBestTime();
|
|
||||||
this.resetGame();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetGame(): void {
|
resetState(): void {
|
||||||
this.stopTimer();
|
this.snake = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }];
|
||||||
this.minesPlaced = false;
|
this.direction = 'UP';
|
||||||
this.isPlaying.set(false);
|
this.nextDirection = 'UP';
|
||||||
|
this.food = { x: 7, y: 3 };
|
||||||
|
this.score.set(0);
|
||||||
this.isGameOver.set(false);
|
this.isGameOver.set(false);
|
||||||
this.isWon.set(false);
|
this.isPaused.set(false);
|
||||||
this.showWinDialog.set(false);
|
this.isPlaying.set(false);
|
||||||
this.face.set('idle');
|
|
||||||
this.elapsedSeconds.set(0);
|
|
||||||
this.isScoreSaved.set(false);
|
this.isScoreSaved.set(false);
|
||||||
|
|
||||||
const newGrid: MinesweeperCell[][] = [];
|
|
||||||
for (let r = 0; r < ROWS; r++) {
|
|
||||||
const row: MinesweeperCell[] = [];
|
|
||||||
for (let c = 0; c < COLS; c++) {
|
|
||||||
row.push({
|
|
||||||
row: r,
|
|
||||||
col: c,
|
|
||||||
isMine: false,
|
|
||||||
isRevealed: false,
|
|
||||||
isFlagged: false,
|
|
||||||
adjacentMines: 0,
|
|
||||||
isExploded: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
newGrid.push(row);
|
|
||||||
}
|
|
||||||
this.grid.set(newGrid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onFaceClick(): void {
|
startGame(): void {
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
this.resetState();
|
||||||
|
this.spawnFood();
|
||||||
|
this.isPlaying.set(true);
|
||||||
|
|
||||||
this.audio.playClick();
|
this.audio.playClick();
|
||||||
this.resetGame();
|
this.lastTickTime = performance.now();
|
||||||
|
this.loop(performance.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
onFaceMouseDown(): void {
|
onCanvasClick(): void {
|
||||||
if (!this.isGameOver() && !this.isWon()) {
|
if (!this.isPlaying()) {
|
||||||
this.face.set('pressed');
|
this.startGame();
|
||||||
|
} else if (this.isGameOver()) {
|
||||||
|
this.startGame();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onFaceMouseUp(): void {
|
togglePause(): void {
|
||||||
if (!this.isGameOver() && !this.isWon()) {
|
if (!this.isPlaying() || this.isGameOver()) return;
|
||||||
this.face.set('idle');
|
this.isPaused.update(p => !p);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleFlagMode(): void {
|
|
||||||
this.audio.playClick();
|
this.audio.playClick();
|
||||||
this.isFlagMode.update(v => !v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCellClick(row: number, col: number): void {
|
@HostListener('window:keydown', ['$event'])
|
||||||
if (this.isGameOver() || this.isWon()) {
|
handleKeyDown(event: KeyboardEvent): void {
|
||||||
this.audio.playClick();
|
const validKeys = [
|
||||||
this.resetGame();
|
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
|
||||||
return;
|
'w', 's', 'a', 'd', 'W', 'S', 'A', 'D', ' ', 'Enter'
|
||||||
}
|
];
|
||||||
|
|
||||||
if (this.isFlagMode()) {
|
if (!validKeys.includes(event.key)) return;
|
||||||
this.toggleFlag(row, col);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.revealCell(row, col);
|
// Prevenir el scroll del navegador con las flechas
|
||||||
}
|
|
||||||
|
|
||||||
onCellRightClick(event: MouseEvent, row: number, col: number): void {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.isGameOver() || this.isWon()) return;
|
|
||||||
this.toggleFlag(row, col);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCellMouseDown(cell: MinesweeperCell): void {
|
// Si aún no está jugando, iniciar inmediatamente al pulsar cualquier flecha o tecla de control
|
||||||
if (this.isGameOver() || this.isWon() || cell.isRevealed || cell.isFlagged) return;
|
if (!this.isPlaying() || this.isGameOver()) {
|
||||||
this.face.set('pressed');
|
this.startGame();
|
||||||
}
|
if (event.key === 'ArrowDown' || event.key === 's' || event.key === 'S') this.nextDirection = 'DOWN';
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'a' || event.key === 'A') this.nextDirection = 'LEFT';
|
||||||
@HostListener('window:mouseup')
|
if (event.key === 'ArrowRight' || event.key === 'd' || event.key === 'D') this.nextDirection = 'RIGHT';
|
||||||
onGlobalMouseUp(): void {
|
|
||||||
if (!this.isGameOver() && !this.isWon()) {
|
|
||||||
this.face.set('idle');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doble clic o clic en número descubierto (Chording clásico)
|
|
||||||
onCellDblClick(row: number, col: number): void {
|
|
||||||
if (this.isGameOver() || this.isWon()) return;
|
|
||||||
const currentGrid = this.grid();
|
|
||||||
const cell = currentGrid[row]?.[col];
|
|
||||||
if (!cell || !cell.isRevealed || cell.adjacentMines === 0) return;
|
|
||||||
|
|
||||||
// Contar banderas adyacentes
|
|
||||||
const neighbors = this.getNeighbors(row, col);
|
|
||||||
const flaggedCount = neighbors.filter(n => currentGrid[n.r][n.c].isFlagged).length;
|
|
||||||
|
|
||||||
if (flaggedCount === cell.adjacentMines) {
|
|
||||||
neighbors.forEach(n => {
|
|
||||||
const target = currentGrid[n.r][n.c];
|
|
||||||
if (!target.isRevealed && !target.isFlagged) {
|
|
||||||
this.revealCell(n.r, n.c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startTimer(): void {
|
|
||||||
this.stopTimer();
|
|
||||||
this.timerInterval = setInterval(() => {
|
|
||||||
this.elapsedSeconds.update(s => Math.min(s + 1, 999));
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopTimer(): void {
|
|
||||||
if (this.timerInterval !== null) {
|
|
||||||
clearInterval(this.timerInterval);
|
|
||||||
this.timerInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateMines(firstRow: number, firstCol: number): void {
|
|
||||||
const currentGrid = this.grid().map(row => row.map(cell => ({ ...cell })));
|
|
||||||
let placed = 0;
|
|
||||||
|
|
||||||
// Generar minas asegurando que la casilla inicial y sus 8 vecinas estén limpias
|
|
||||||
while (placed < TOTAL_MINES) {
|
|
||||||
const r = Math.floor(Math.random() * ROWS);
|
|
||||||
const c = Math.floor(Math.random() * COLS);
|
|
||||||
|
|
||||||
const isFirstSafeZone = Math.abs(r - firstRow) <= 1 && Math.abs(c - firstCol) <= 1;
|
|
||||||
|
|
||||||
if (!currentGrid[r][c].isMine && !isFirstSafeZone) {
|
|
||||||
currentGrid[r][c].isMine = true;
|
|
||||||
placed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular números adyacentes
|
|
||||||
for (let r = 0; r < ROWS; r++) {
|
|
||||||
for (let c = 0; c < COLS; c++) {
|
|
||||||
if (!currentGrid[r][c].isMine) {
|
|
||||||
let count = 0;
|
|
||||||
for (let dr = -1; dr <= 1; dr++) {
|
|
||||||
for (let dc = -1; dc <= 1; dc++) {
|
|
||||||
if (dr === 0 && dc === 0) continue;
|
|
||||||
const nr = r + dr;
|
|
||||||
const nc = c + dc;
|
|
||||||
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && currentGrid[nr][nc].isMine) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentGrid[r][c].adjacentMines = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.grid.set(currentGrid);
|
|
||||||
this.minesPlaced = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private revealCell(row: number, col: number): void {
|
|
||||||
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
|
||||||
const cell = currentGrid[row]?.[col];
|
|
||||||
|
|
||||||
if (!cell || cell.isRevealed || cell.isFlagged) return;
|
|
||||||
|
|
||||||
// Primer clic: colocar minas e iniciar cronómetro
|
|
||||||
if (!this.minesPlaced) {
|
|
||||||
this.generateMines(row, col);
|
|
||||||
this.startTimer();
|
|
||||||
this.isPlaying.set(true);
|
|
||||||
// Re-obtener la celda actualizada tras colocar minas
|
|
||||||
const updatedGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
|
||||||
this.floodFill(updatedGrid, row, col);
|
|
||||||
this.grid.set(updatedGrid);
|
|
||||||
this.audio.playCellReveal();
|
|
||||||
this.checkWinCondition(updatedGrid);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si hace clic en una mina: GAME OVER
|
switch (event.key) {
|
||||||
if (cell.isMine) {
|
case 'ArrowUp':
|
||||||
cell.isRevealed = true;
|
case 'w':
|
||||||
cell.isExploded = true;
|
case 'W':
|
||||||
this.grid.set(currentGrid);
|
if (this.direction !== 'DOWN') this.nextDirection = 'UP';
|
||||||
this.handleGameOver(row, col);
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
if (this.direction !== 'UP') this.nextDirection = 'DOWN';
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
if (this.direction !== 'RIGHT') this.nextDirection = 'LEFT';
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'd':
|
||||||
|
case 'D':
|
||||||
|
if (this.direction !== 'LEFT') this.nextDirection = 'RIGHT';
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
this.togglePause();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirection(newDir: Direction): void {
|
||||||
|
if (!this.isPlaying() || this.isGameOver()) {
|
||||||
|
this.startGame();
|
||||||
|
this.nextDirection = newDir;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isPaused()) return;
|
||||||
|
if (newDir === 'UP' && this.direction !== 'DOWN') this.nextDirection = 'UP';
|
||||||
|
if (newDir === 'DOWN' && this.direction !== 'UP') this.nextDirection = 'DOWN';
|
||||||
|
if (newDir === 'LEFT' && this.direction !== 'RIGHT') this.nextDirection = 'LEFT';
|
||||||
|
if (newDir === 'RIGHT' && this.direction !== 'LEFT') this.nextDirection = 'RIGHT';
|
||||||
|
}
|
||||||
|
|
||||||
|
private loop = (timestamp: number): void => {
|
||||||
|
if (!this.isPlaying()) return;
|
||||||
|
|
||||||
|
if (!this.isPaused() && !this.isGameOver()) {
|
||||||
|
if (timestamp - this.lastTickTime >= TICK_RATE_MS) {
|
||||||
|
this.update();
|
||||||
|
this.lastTickTime = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
if (!this.isGameOver()) {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.loop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private update(): void {
|
||||||
|
this.direction = this.nextDirection;
|
||||||
|
const head = { ...this.snake[0] };
|
||||||
|
|
||||||
|
switch (this.direction) {
|
||||||
|
case 'UP': head.y -= 1; break;
|
||||||
|
case 'DOWN': head.y += 1; break;
|
||||||
|
case 'LEFT': head.x -= 1; break;
|
||||||
|
case 'RIGHT': head.x += 1; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choque con paredes
|
||||||
|
if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) {
|
||||||
|
this.triggerGameOver();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Casilla segura: descubrir con flood-fill si está vacía
|
// Choque con el propio cuerpo
|
||||||
this.floodFill(currentGrid, row, col);
|
if (this.snake.some(segment => segment.x === head.x && segment.y === head.y)) {
|
||||||
this.grid.set(currentGrid);
|
this.triggerGameOver();
|
||||||
this.audio.playCellReveal();
|
return;
|
||||||
this.checkWinCondition(currentGrid);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private floodFill(grid: MinesweeperCell[][], row: number, col: number): void {
|
this.snake.unshift(head);
|
||||||
const cell = grid[row]?.[col];
|
|
||||||
if (!cell || cell.isRevealed || cell.isFlagged || cell.isMine) return;
|
|
||||||
|
|
||||||
cell.isRevealed = true;
|
// Comer fruta
|
||||||
|
if (head.x === this.food.x && head.y === this.food.y) {
|
||||||
// Si no tiene minas adyacentes (casilla vacía '0'), expandir en cascada
|
const nextScore = this.score() + 10;
|
||||||
if (cell.adjacentMines === 0) {
|
this.score.set(nextScore);
|
||||||
const neighbors = this.getNeighbors(row, col);
|
if (nextScore > this.highScore()) {
|
||||||
for (const n of neighbors) {
|
this.highScore.set(nextScore);
|
||||||
this.floodFill(grid, n.r, n.c);
|
|
||||||
}
|
}
|
||||||
|
this.audio.playSnakeEat();
|
||||||
|
this.spawnFood();
|
||||||
|
} else {
|
||||||
|
this.snake.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleFlag(row: number, col: number): void {
|
private triggerGameOver(): void {
|
||||||
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
|
||||||
const cell = currentGrid[row]?.[col];
|
|
||||||
|
|
||||||
if (!cell || cell.isRevealed) return;
|
|
||||||
|
|
||||||
cell.isFlagged = !cell.isFlagged;
|
|
||||||
this.grid.set(currentGrid);
|
|
||||||
this.audio.playFlagToggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNeighbors(r: number, c: number): Array<{ r: number; c: number }> {
|
|
||||||
const neighbors: Array<{ r: number; c: number }> = [];
|
|
||||||
for (let dr = -1; dr <= 1; dr++) {
|
|
||||||
for (let dc = -1; dc <= 1; dc++) {
|
|
||||||
if (dr === 0 && dc === 0) continue;
|
|
||||||
const nr = r + dr;
|
|
||||||
const nc = c + dc;
|
|
||||||
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) {
|
|
||||||
neighbors.push({ r: nr, c: nc });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return neighbors;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleGameOver(explodedRow: number, explodedCol: number): void {
|
|
||||||
this.stopTimer();
|
|
||||||
this.isGameOver.set(true);
|
this.isGameOver.set(true);
|
||||||
this.face.set('dead');
|
this.audio.playGameOver();
|
||||||
this.audio.playMineExplosion();
|
this.render(); // Renderizar el estado de choque final
|
||||||
|
|
||||||
// Revelar todas las demás minas
|
|
||||||
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
|
||||||
for (let r = 0; r < ROWS; r++) {
|
|
||||||
for (let c = 0; c < COLS; c++) {
|
|
||||||
const cell = currentGrid[r][c];
|
|
||||||
if (cell.isMine && !cell.isFlagged) {
|
|
||||||
cell.isRevealed = true;
|
|
||||||
}
|
|
||||||
// Bandera incorrecta
|
|
||||||
if (!cell.isMine && cell.isFlagged) {
|
|
||||||
cell.isRevealed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.grid.set(currentGrid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkWinCondition(grid: MinesweeperCell[][]): void {
|
private spawnFood(): void {
|
||||||
let revealedSafeCount = 0;
|
let valid = false;
|
||||||
const totalSafeCells = ROWS * COLS - TOTAL_MINES;
|
let newX = 0;
|
||||||
|
let newY = 0;
|
||||||
|
while (!valid) {
|
||||||
|
newX = Math.floor(Math.random() * GRID_SIZE);
|
||||||
|
newY = Math.floor(Math.random() * GRID_SIZE);
|
||||||
|
valid = !this.snake.some(s => s.x === newX && s.y === newY);
|
||||||
|
}
|
||||||
|
this.food = { x: newX, y: newY };
|
||||||
|
}
|
||||||
|
|
||||||
for (let r = 0; r < ROWS; r++) {
|
private render(): void {
|
||||||
for (let c = 0; c < COLS; c++) {
|
if (!this.ctx) return;
|
||||||
if (!grid[r][c].isMine && grid[r][c].isRevealed) {
|
const ctx = this.ctx;
|
||||||
revealedSafeCount++;
|
const width = GRID_SIZE * CELL_SIZE; // 300
|
||||||
|
const height = GRID_SIZE * CELL_SIZE; // 300
|
||||||
|
|
||||||
|
// Tablero estilo Arcade clásico con cuadrícula nítida
|
||||||
|
ctx.fillStyle = '#1c2833';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Cuadrícula suave de casillas
|
||||||
|
for (let r = 0; r < GRID_SIZE; r++) {
|
||||||
|
for (let c = 0; c < GRID_SIZE; c++) {
|
||||||
|
if ((r + c) % 2 === 0) {
|
||||||
|
ctx.fillStyle = '#212f3d';
|
||||||
|
ctx.fillRect(c * CELL_SIZE, r * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (revealedSafeCount === totalSafeCells) {
|
// Borde exterior
|
||||||
this.stopTimer();
|
ctx.strokeStyle = '#34495e';
|
||||||
this.isWon.set(true);
|
ctx.lineWidth = 2;
|
||||||
this.face.set('won');
|
ctx.strokeRect(1, 1, width - 2, height - 2);
|
||||||
this.showWinDialog.set(true);
|
|
||||||
this.audio.playVictory();
|
|
||||||
|
|
||||||
// Auto-marcar todas las minas restantes con banderas
|
// 1. DIBUJAR FRUTA (Manzana roja con tallo y hoja)
|
||||||
const finalGrid = grid.map(r => r.map(c => ({
|
const fx = this.food.x * CELL_SIZE + CELL_SIZE / 2;
|
||||||
...c,
|
const fy = this.food.y * CELL_SIZE + CELL_SIZE / 2;
|
||||||
isFlagged: c.isMine ? true : c.isFlagged
|
|
||||||
})));
|
|
||||||
this.grid.set(finalGrid);
|
|
||||||
|
|
||||||
const time = this.elapsedSeconds();
|
ctx.save();
|
||||||
const currentBest = this.bestTime();
|
// Brillo de la manzana
|
||||||
if (currentBest === null || time < currentBest) {
|
ctx.fillStyle = '#e74c3c';
|
||||||
this.bestTime.set(time);
|
ctx.beginPath();
|
||||||
|
ctx.arc(fx, fy, 8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Tallo marrón y hoja verde
|
||||||
|
ctx.fillStyle = '#795548';
|
||||||
|
ctx.fillRect(fx - 1, fy - 10, 2, 4);
|
||||||
|
ctx.fillStyle = '#2ecc71';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(fx + 3, fy - 8, 3, 1.5, Math.PI / 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 2. DIBUJAR SERPIENTE (Verde brillante y con ojos claramente visibles)
|
||||||
|
this.snake.forEach((seg, i) => {
|
||||||
|
const isHead = i === 0;
|
||||||
|
const px = seg.x * CELL_SIZE;
|
||||||
|
const py = seg.y * CELL_SIZE;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (isHead) {
|
||||||
|
// Cabeza verde lima destacada
|
||||||
|
ctx.fillStyle = this.isGameOver() ? '#e74c3c' : '#2ecc71';
|
||||||
|
ctx.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
|
||||||
|
|
||||||
|
// Ojos blancos con pupilas negras
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
if (this.direction === 'UP' || this.direction === 'DOWN') {
|
||||||
|
ctx.fillRect(px + 3, py + 4, 4, 4);
|
||||||
|
ctx.fillRect(px + 13, py + 4, 4, 4);
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillRect(px + 4, py + (this.direction === 'UP' ? 4 : 6), 2, 2);
|
||||||
|
ctx.fillRect(px + 14, py + (this.direction === 'UP' ? 4 : 6), 2, 2);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(px + 4, py + 3, 4, 4);
|
||||||
|
ctx.fillRect(px + 4, py + 13, 4, 4);
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillRect(px + (this.direction === 'LEFT' ? 4 : 6), py + 4, 2, 2);
|
||||||
|
ctx.fillRect(px + (this.direction === 'LEFT' ? 4 : 6), py + 14, 2, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cuerpo verde esmeralda con borde
|
||||||
|
ctx.fillStyle = '#27ae60';
|
||||||
|
ctx.fillRect(px + 2, py + 2, CELL_SIZE - 4, CELL_SIZE - 4);
|
||||||
|
ctx.strokeStyle = '#1e8449';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px + 2, py + 2, CELL_SIZE - 4, CELL_SIZE - 4);
|
||||||
}
|
}
|
||||||
}
|
ctx.restore();
|
||||||
}
|
});
|
||||||
|
|
||||||
closeWinDialog(): void {
|
|
||||||
this.audio.playClick();
|
|
||||||
this.showWinDialog.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
submitScoreAndClose(): void {
|
|
||||||
this.submitScore();
|
|
||||||
this.showWinDialog.set(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submitScore(): void {
|
submitScore(): void {
|
||||||
const finalTime = this.elapsedSeconds();
|
const finalScore = this.score();
|
||||||
const tag = (this.playerTag() || 'XP').trim().toUpperCase().slice(0, 4);
|
const tag = (this.playerTag() || 'XP').trim().toUpperCase().slice(0, 4);
|
||||||
if (finalTime <= 0 || this.isScoreSaved()) return;
|
if (finalScore <= 0 || this.isScoreSaved()) return;
|
||||||
|
|
||||||
this.scorePort.saveScore({
|
this.scorePort.saveScore({
|
||||||
playerTag: tag,
|
playerTag: tag,
|
||||||
gameCode: 'MINESWEEPER',
|
gameCode: 'ZEN_SNAKE',
|
||||||
score: finalTime
|
score: finalScore
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isScoreSaved.set(true);
|
this.isScoreSaved.set(true);
|
||||||
this.audio.playHighScore();
|
this.audio.playHighScore();
|
||||||
this.loadBestTime();
|
this.loadHighScore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadBestTime(): void {
|
private loadHighScore(): void {
|
||||||
this.scorePort.getTopScores('MINESWEEPER', 1).subscribe({
|
this.scorePort.getTopScores('ZEN_SNAKE', 1).subscribe({
|
||||||
next: (scores) => {
|
next: (scores) => {
|
||||||
if (scores.length > 0) {
|
if (scores.length > 0 && scores[0].score > this.highScore()) {
|
||||||
this.bestTime.set(scores[0].score);
|
this.highScore.set(scores[0].score);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.stopTimer();
|
this.isPlaying.set(false);
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 50;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
|
||||||
1. PANTALLA APAGADA (CRT STANDBY)
|
|
||||||
============================================================================== */
|
|
||||||
.crt-off-screen {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: #000000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standby-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: rgba(18, 21, 27, 0.7);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standby-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #e69d45;
|
|
||||||
box-shadow: 0 0 8px #e69d45;
|
|
||||||
animation: crt-standby-blink 1.8s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standby-text {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: #a0aab8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes crt-standby-blink {
|
|
||||||
0%, 100% { opacity: 0.3; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
|
||||||
2. PANTALLA DE INICIO WINDOWS XP (BOOT SCREEN)
|
|
||||||
============================================================================== */
|
|
||||||
.xp-boot-screen {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: #000000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-boot-logo {
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boot-flag-svg {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8));
|
|
||||||
}
|
|
||||||
|
|
||||||
.boot-text-brand {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-ms {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: #ffffff;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-xp {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-windows {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 1.45rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #ffffff;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-xp-badge {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #EB3C00;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Barra de Carga de XP */
|
|
||||||
.xp-loader-container {
|
|
||||||
width: 140px;
|
|
||||||
height: 14px;
|
|
||||||
background: #000000;
|
|
||||||
border: 1px solid #5a5a5a;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 2px;
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.9);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-loader-track {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-loader-slider {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -40px;
|
|
||||||
height: 100%;
|
|
||||||
animation: xp-progress-slide 1.4s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-loader-slider .block {
|
|
||||||
width: 9px;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(180deg, #3870e8 0%, #204db5 50%, #153282 100%);
|
|
||||||
border-radius: 1px;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes xp-progress-slide {
|
|
||||||
0% { left: -36px; }
|
|
||||||
100% { left: 140px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.boot-footer-note {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 12px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.5625rem;
|
|
||||||
color: #666666;
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<div class="power-overlay" [class.state-off]="powerState() === 'off'"
|
|
||||||
[class.state-booting]="powerState() === 'booting'" (click)="onScreenClick()">
|
|
||||||
|
|
||||||
<!-- 1. Pantalla Apagada (CRT Standby) -->
|
|
||||||
@if (powerState() === 'off') {
|
|
||||||
<div class="crt-off-screen">
|
|
||||||
<div class="standby-indicator">
|
|
||||||
<span class="standby-dot"></span>
|
|
||||||
<span class="standby-text">STANDBY // CLICK TO POWER ON</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- 2. Pantalla de Inicio Windows XP (Microsoft Windows XP + Barra de carga) -->
|
|
||||||
@if (powerState() === 'booting') {
|
|
||||||
<div class="xp-boot-screen">
|
|
||||||
<!-- Logo Oficial Minimalista de Windows XP en SVG Nítido -->
|
|
||||||
<div class="xp-boot-logo">
|
|
||||||
<div class="logo-row">
|
|
||||||
<svg viewBox="0 0 88 88" class="boot-flag-svg" aria-hidden="true">
|
|
||||||
<path d="M4 14C12 10 24 11 32 17C35 20 35 30 35 39C27 34 16 32 4 36V14Z" fill="#EB3C00" />
|
|
||||||
<path d="M43 19C52 13 65 11 75 13V36C65 33 53 35 43 40V19Z" fill="#46A200" />
|
|
||||||
<path d="M4 44C16 40 27 42 35 47V70C27 64 16 66 4 71V44Z" fill="#0080FF" />
|
|
||||||
<path d="M43 48C53 43 65 41 75 44V68C65 65 52 67 43 72V48Z" fill="#FFB700" />
|
|
||||||
</svg>
|
|
||||||
<div class="boot-text-brand">
|
|
||||||
<span class="brand-ms">Microsoft</span>
|
|
||||||
<div class="brand-xp">
|
|
||||||
<span class="brand-windows">Windows</span>
|
|
||||||
<span class="brand-xp-badge">XP</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mítica Barra de Progreso XP con los 3 bloques azules deslizantes -->
|
|
||||||
<div class="xp-loader-container">
|
|
||||||
<div class="xp-loader-track">
|
|
||||||
<div class="xp-loader-slider">
|
|
||||||
<span class="block"></span>
|
|
||||||
<span class="block"></span>
|
|
||||||
<span class="block"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="boot-footer-note">
|
|
||||||
<span>Copyright © Microsoft Corporation</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
inject
|
|
||||||
} from '@angular/core';
|
|
||||||
import { OsPowerState } from '../../../../../domain/models/retro-os.model';
|
|
||||||
import { I18nService } from '../../../../../application/services/i18n.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-retro-power-screen',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './retro-power-screen.component.html',
|
|
||||||
styleUrl: './retro-power-screen.component.css',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class RetroPowerScreenComponent {
|
|
||||||
readonly powerState = input.required<OsPowerState>();
|
|
||||||
readonly powerOn = output<void>();
|
|
||||||
|
|
||||||
protected readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
onScreenClick(): void {
|
|
||||||
if (this.powerState() === 'off') {
|
|
||||||
this.powerOn.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,7 +25,7 @@ export class RetroScoresComponent implements OnInit {
|
||||||
|
|
||||||
loadScores(): void {
|
loadScores(): void {
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.scorePort.getTopScores('MINESWEEPER', 10).subscribe({
|
this.scorePort.getTopScores('ZEN_SNAKE', 10).subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
this.scores.set(data);
|
this.scores.set(data);
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<div class="start-sep"></div>
|
<div class="start-sep"></div>
|
||||||
|
|
||||||
<div class="start-item" (click)="onSelect('games')">
|
<div class="start-item" (click)="onSelect('games')">
|
||||||
<span class="item-icon game-icon">💣</span>
|
<span class="item-icon game-icon">🎮</span>
|
||||||
<span class="item-title bold">{{ i18n.t().retroMenuGames }}</span>
|
<span class="item-title bold">{{ i18n.t().retroMenuGames }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="start-item" (click)="onSelect()">
|
<div class="start-item" (click)="onSelect()">
|
||||||
|
|
@ -90,11 +90,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pie del Menú Inicio: Cerrar sesión -->
|
<!-- Pie del Menú Inicio: Cerrar sesión y Apagar -->
|
||||||
<footer class="start-footer">
|
<footer class="start-footer">
|
||||||
<button type="button" class="footer-action-btn" (click)="onSelect()">
|
<button type="button" class="footer-action-btn" (click)="onSelect()">
|
||||||
<span class="key-icon">🔑</span>
|
<span class="key-icon">🔑</span>
|
||||||
<span>{{ i18n.t().retroMenuLogoff }}</span>
|
<span>{{ i18n.t().retroMenuLogoff }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="footer-action-btn power" (click)="onSelect()">
|
||||||
|
<span class="power-icon">⏻</span>
|
||||||
|
<span>{{ i18n.t().retroMenuShutdown }}</span>
|
||||||
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -174,33 +174,3 @@
|
||||||
.tray-expand { font-size: 0.5rem; background: #1754ce; border-radius: 50%; width: 11px; height: 11px; display: flex; align-items: center; justify-content: center; }
|
.tray-expand { font-size: 0.5rem; background: #1754ce; border-radius: 50%; width: 11px; height: 11px; display: flex; align-items: center; justify-content: center; }
|
||||||
.tray-mini-icon { font-size: 0.625rem; }
|
.tray-mini-icon { font-size: 0.625rem; }
|
||||||
.xp-tray-clock { font-family: var(--font-sans); font-size: 0.6875rem; font-weight: 600; margin-left: 2px; }
|
.xp-tray-clock { font-family: var(--font-sans); font-size: 0.6875rem; font-weight: 600; margin-left: 2px; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.xp-custom-taskbar {
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
.xp-start-button {
|
|
||||||
height: 22px;
|
|
||||||
width: 64px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
.start-text {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
}
|
|
||||||
.start-flag, .xp-flag-svg {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
.xp-quick-launch {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.xp-tray-clock {
|
|
||||||
font-size: 0.5625rem;
|
|
||||||
}
|
|
||||||
.tray-mini-icon {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tray-expand {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@
|
||||||
|
|
||||||
<!-- 2. Pantalla Windows XP Sobrepuesta en el Área del Tubo (100% Visible e Interactiva) -->
|
<!-- 2. Pantalla Windows XP Sobrepuesta en el Área del Tubo (100% Visible e Interactiva) -->
|
||||||
<div class="monitor-screen-glass">
|
<div class="monitor-screen-glass">
|
||||||
<!-- Pantalla de Estado de Energía (Apagado / Booting / Welcome / Shutdown) -->
|
|
||||||
@if (store.powerState() !== 'desktop') {
|
|
||||||
<app-retro-power-screen [powerState]="store.powerState()" (powerOn)="store.startBootSequence()" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="xp-desktop-canvas">
|
<div class="xp-desktop-canvas">
|
||||||
<!-- Iconos de Escritorio de Windows XP -->
|
<!-- Iconos de Escritorio de Windows XP -->
|
||||||
<app-retro-desktop-icons [icons]="store.desktopIcons()"
|
<app-retro-desktop-icons [icons]="store.desktopIcons()"
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@ import {
|
||||||
Component,
|
Component,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
OnInit,
|
OnInit,
|
||||||
AfterViewInit,
|
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
signal,
|
signal,
|
||||||
inject,
|
inject
|
||||||
ElementRef
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RetroOsStore } from '../../../application/stores/retro-os.store';
|
import { RetroOsStore } from '../../../application/stores/retro-os.store';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
@ -18,7 +16,6 @@ import { RetroCvComponent } from './components/retro-cv/retro-cv.component';
|
||||||
import { RetroScoresComponent } from './components/retro-scores/retro-scores.component';
|
import { RetroScoresComponent } from './components/retro-scores/retro-scores.component';
|
||||||
import { RetroSettingsComponent } from './components/retro-settings/retro-settings.component';
|
import { RetroSettingsComponent } from './components/retro-settings/retro-settings.component';
|
||||||
import { RetroStartMenuComponent } from './components/retro-start-menu/retro-start-menu.component';
|
import { RetroStartMenuComponent } from './components/retro-start-menu/retro-start-menu.component';
|
||||||
import { RetroPowerScreenComponent } from './components/retro-power-screen/retro-power-screen.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-retro-os',
|
selector: 'app-retro-os',
|
||||||
|
|
@ -30,25 +27,20 @@ import { RetroPowerScreenComponent } from './components/retro-power-screen/retro
|
||||||
RetroCvComponent,
|
RetroCvComponent,
|
||||||
RetroScoresComponent,
|
RetroScoresComponent,
|
||||||
RetroSettingsComponent,
|
RetroSettingsComponent,
|
||||||
RetroStartMenuComponent,
|
RetroStartMenuComponent
|
||||||
RetroPowerScreenComponent
|
|
||||||
],
|
],
|
||||||
templateUrl: './retro-os.component.html',
|
templateUrl: './retro-os.component.html',
|
||||||
styleUrl: './retro-os.component.css',
|
styleUrl: './retro-os.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class RetroOsComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class RetroOsComponent implements OnInit, OnDestroy {
|
||||||
protected readonly store = inject(RetroOsStore);
|
protected readonly store = inject(RetroOsStore);
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
private readonly elRef = inject(ElementRef);
|
|
||||||
|
|
||||||
protected readonly isStartMenuOpen = signal<boolean>(false);
|
protected readonly isStartMenuOpen = signal<boolean>(false);
|
||||||
protected readonly currentTime = signal<string>('12:00');
|
protected readonly currentTime = signal<string>('12:00');
|
||||||
|
|
||||||
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private observer: IntersectionObserver | null = null;
|
|
||||||
private hasBootedOnce = false;
|
|
||||||
|
|
||||||
private readonly timeFormatter = new Intl.DateTimeFormat('es-EC', {
|
private readonly timeFormatter = new Intl.DateTimeFormat('es-EC', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
|
@ -60,27 +52,6 @@ export class RetroOsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.clockInterval = setInterval(() => this.updateClock(), 1000);
|
this.clockInterval = setInterval(() => this.updateClock(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
if (typeof window !== 'undefined' && 'IntersectionObserver' in window) {
|
|
||||||
this.observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const entry = entries[0];
|
|
||||||
// Al entrar visible en pantalla tras bajar del Hero 3D
|
|
||||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.25) {
|
|
||||||
if (this.store.powerState() === 'off' && !this.hasBootedOnce) {
|
|
||||||
this.hasBootedOnce = true;
|
|
||||||
this.store.startBootSequence();
|
|
||||||
// Desconectar observer: una vez encendido se queda siempre cargado
|
|
||||||
this.observer?.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: [0.25] }
|
|
||||||
);
|
|
||||||
this.observer.observe(this.elRef.nativeElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateClock(): void {
|
private updateClock(): void {
|
||||||
this.currentTime.set(this.timeFormatter.format(new Date()));
|
this.currentTime.set(this.timeFormatter.format(new Date()));
|
||||||
}
|
}
|
||||||
|
|
@ -104,8 +75,5 @@ export class RetroOsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
if (this.clockInterval !== null) {
|
if (this.clockInterval !== null) {
|
||||||
clearInterval(this.clockInterval);
|
clearInterval(this.clockInterval);
|
||||||
}
|
}
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@
|
||||||
.profile-editorial-content {
|
.profile-editorial-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0;
|
gap: var(--space-4);
|
||||||
padding: 0;
|
padding: var(--space-2) 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
@ -20,180 +19,118 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-me {
|
/* Eyebrow */
|
||||||
display: flow-root;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-copy {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editorial-eyebrow {
|
.editorial-eyebrow {
|
||||||
display: block;
|
|
||||||
margin: 0 0 var(--space-1) 0;
|
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: clamp(0.85rem, 2cqw, 1.1rem);
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.12em;
|
||||||
color: var(--zen-accent-kintsugi);
|
color: var(--zen-accent-kintsugi);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
|
||||||
|
|
||||||
.about-greeting {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
font-size: clamp(0.95rem, 1.8cqw, 1.125rem);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editorial-title {
|
|
||||||
margin: 0 0 var(--space-4) 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: clamp(2rem, 5cqw, 3rem);
|
|
||||||
font-weight: 900;
|
|
||||||
color: #ffffff;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-description {
|
|
||||||
margin: 0 0 var(--space-4) 0;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
font-size: clamp(0.875rem, 1.55cqw, 1rem);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-link {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: var(--space-2);
|
}
|
||||||
color: var(--zen-text-gold);
|
|
||||||
|
/* Título Principal H2 */
|
||||||
|
.editorial-title {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: clamp(1rem, 2cqw, 1.25rem);
|
font-size: clamp(1.85rem, 3vw, 2.35rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Párrafo Lead */
|
||||||
|
.editorial-lead {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--zen-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-lead strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secciones de Contenido */
|
||||||
|
.editorial-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-bullet {
|
||||||
|
color: var(--zen-accent-kintsugi);
|
||||||
|
font-size: 0.875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ascii-portrait {
|
.editorial-subtitle {
|
||||||
float: left;
|
font-family: var(--font-sans);
|
||||||
width: 58%;
|
font-size: 1.15rem;
|
||||||
min-width: 0;
|
font-weight: 700;
|
||||||
margin: var(--space-2) 0 0 0;
|
color: #ffffff;
|
||||||
padding: 0;
|
|
||||||
overflow: visible;
|
|
||||||
color: var(--zen-text-gold);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: clamp(1.8px, calc(58cqw / 108), 4.2px);
|
|
||||||
line-height: 0.84;
|
|
||||||
text-align: left;
|
|
||||||
shape-outside: polygon(
|
|
||||||
0% 0%,
|
|
||||||
49.4% 0%,
|
|
||||||
52.2% 4.3%,
|
|
||||||
59.4% 8.6%,
|
|
||||||
62.8% 16.1%,
|
|
||||||
65.0% 32.3%,
|
|
||||||
63.3% 36.6%,
|
|
||||||
58.9% 39.8%,
|
|
||||||
57.2% 44.1%,
|
|
||||||
58.3% 49.5%,
|
|
||||||
63.3% 53.8%,
|
|
||||||
70.6% 57.0%,
|
|
||||||
80.6% 60.2%,
|
|
||||||
90.0% 63.4%,
|
|
||||||
91.7% 66.7%,
|
|
||||||
93.3% 73.1%,
|
|
||||||
95.0% 82.8%,
|
|
||||||
97.8% 92.5%,
|
|
||||||
100% 100%,
|
|
||||||
0% 100%
|
|
||||||
);
|
|
||||||
shape-margin: 20px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ascii-portrait pre {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
letter-spacing: -0.01em;
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
white-space: pre;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ascii-status {
|
/* Grid de Categorías Técnicas */
|
||||||
display: block;
|
.skills-grid {
|
||||||
max-width: 120px;
|
display: flex;
|
||||||
font-family: var(--font-mono);
|
flex-direction: column;
|
||||||
font-size: 0.65rem;
|
gap: 6px;
|
||||||
line-height: 1.4;
|
padding-left: 14px;
|
||||||
color: var(--zen-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
.skill-category {
|
||||||
.ascii-portrait {
|
font-family: var(--font-sans);
|
||||||
width: 56%;
|
font-size: 0.8125rem;
|
||||||
shape-margin: 16px;
|
line-height: 1.5;
|
||||||
font-size: clamp(1.8px, calc(56cqw / 108), 3.6px);
|
color: var(--zen-text-secondary);
|
||||||
}
|
|
||||||
|
|
||||||
.editorial-eyebrow {
|
|
||||||
font-size: clamp(0.8rem, 1.8cqw, 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editorial-title {
|
|
||||||
font-size: clamp(1.8rem, 4.5cqw, 2.6rem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.cat-label {
|
||||||
.profile-editorial-content {
|
font-family: var(--font-sans);
|
||||||
height: auto;
|
font-weight: 700;
|
||||||
}
|
color: #7dcfff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
.about-me {
|
margin-right: 6px;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
.cat-techs {
|
||||||
padding: var(--space-4) 0;
|
color: #c0caf5;
|
||||||
min-height: auto;
|
}
|
||||||
}
|
|
||||||
|
/* Lista de Áreas de Interés */
|
||||||
.about-copy {
|
.interests-list {
|
||||||
width: 100%;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0 0 0 14px;
|
||||||
}
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
.editorial-eyebrow {
|
flex-direction: column;
|
||||||
font-size: clamp(0.75rem, 3.5vw, 0.95rem);
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorial-title {
|
.interest-item {
|
||||||
font-size: clamp(1.6rem, 7vw, 2.2rem);
|
font-family: var(--font-sans);
|
||||||
}
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
.ascii-portrait {
|
color: var(--zen-text-secondary);
|
||||||
float: none;
|
}
|
||||||
width: 100%;
|
|
||||||
max-width: 380px;
|
.interest-key {
|
||||||
margin: var(--space-6) auto 0 auto;
|
font-family: var(--font-sans);
|
||||||
shape-outside: none;
|
font-weight: 700;
|
||||||
font-size: clamp(1.6px, calc(95cqw / 108), 3.2px);
|
color: #bb9af7;
|
||||||
text-align: center;
|
font-size: 0.8125rem;
|
||||||
}
|
margin-right: 6px;
|
||||||
|
|
||||||
.ascii-portrait pre {
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: left;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,74 @@
|
||||||
<div class="profile-editorial-content">
|
<div class="profile-editorial-content">
|
||||||
<section class="about-me" [attr.aria-label]="i18n.t().tpAboutAria">
|
<!-- Eyebrow en Dorado Kintsugi -->
|
||||||
<div class="about-copy">
|
<span class="editorial-eyebrow">
|
||||||
<span class="editorial-eyebrow">{{ i18n.t().tpEyebrow }}</span>
|
{{ i18n.t().tpEyebrow }}
|
||||||
<p class="about-greeting">{{ i18n.t().tpAboutGreeting }}</p>
|
</span>
|
||||||
|
|
||||||
<figure class="ascii-portrait">
|
<!-- Título Principal H2 -->
|
||||||
@if (asciiArt()) {
|
<h2 class="editorial-title">
|
||||||
<pre [attr.aria-label]="i18n.t().tpAboutAria">{{ asciiArt() }}</pre>
|
{{ i18n.t().tpTitle }}
|
||||||
} @else if (asciiLoadError()) {
|
</h2>
|
||||||
<figcaption class="ascii-status" role="status">{{ i18n.t().tpAsciiError }}</figcaption>
|
|
||||||
} @else {
|
|
||||||
<figcaption class="ascii-status" role="status">{{ i18n.t().tpAsciiLoading }}</figcaption>
|
|
||||||
}
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
<h2 class="editorial-title">{{ i18n.t().tpAboutName }}</h2>
|
<!-- Breve Perfil Extraído del CV -->
|
||||||
@for (paragraph of aboutParagraphs(); track $index) {
|
<p class="editorial-lead">
|
||||||
<p class="about-description">{{ paragraph }}</p>
|
{{ i18n.t().tpLeadStudent }} <strong>Escuela Politécnica Nacional (EPN)</strong>.
|
||||||
}
|
{{ i18n.t().tpLeadSpecialization }} <strong>{{ i18n.t().tpLeadStack }}</strong>{{ i18n.t().tpLeadEnd }}
|
||||||
<span class="about-link">{{ i18n.t().tpAboutLink }} <span aria-hidden="true">→</span></span>
|
</p>
|
||||||
|
|
||||||
|
<!-- 1. Bloque: Habilidades Técnicas Principales -->
|
||||||
|
<div class="editorial-section">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<span class="section-bullet">▹</span>
|
||||||
|
<h3 class="editorial-subtitle">{{ i18n.t().tpSkillsSubtitle }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<div class="skills-grid">
|
||||||
|
<!-- Backend -->
|
||||||
|
<div class="skill-category">
|
||||||
|
<strong class="cat-label">{{ i18n.t().tpCatBackend }}</strong>
|
||||||
|
<span class="cat-techs">.NET 8/9, C#, Clean Architecture, CQRS (MediatR), FluentValidation, REST APIs.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frontend -->
|
||||||
|
<div class="skill-category">
|
||||||
|
<strong class="cat-label">{{ i18n.t().tpCatFrontend }}</strong>
|
||||||
|
<span class="cat-techs">Angular 16+, Signals reactivos, ChangeDetection OnPush, TypeScript, Canvas 2D.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datos & Cloud -->
|
||||||
|
<div class="skill-category">
|
||||||
|
<strong class="cat-label">{{ i18n.t().tpCatData }}</strong>
|
||||||
|
<span class="cat-techs">MySQL, PostgreSQL, SQLite, Docker, AWS (Academy Graduate), Git/GitHub CI/CD.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ciberseguridad & Redes -->
|
||||||
|
<div class="skill-category">
|
||||||
|
<strong class="cat-label">{{ i18n.t().tpCatSecurity }}</strong>
|
||||||
|
<span class="cat-techs">{{ i18n.t().tpCatSecurityDesc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Bloque: Áreas de Interés & Cultura Dev -->
|
||||||
|
<div class="editorial-section">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<span class="section-bullet">▹</span>
|
||||||
|
<h3 class="editorial-subtitle">{{ i18n.t().tpInterestsSubtitle }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="interests-list">
|
||||||
|
<li class="interest-item">
|
||||||
|
<strong class="interest-key">{{ i18n.t().tpInterest1Key }}</strong>
|
||||||
|
<span>{{ i18n.t().tpInterest1Desc }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="interest-item">
|
||||||
|
<strong class="interest-key">{{ i18n.t().tpInterest2Key }}</strong>
|
||||||
|
<span>{{ i18n.t().tpInterest2Desc }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="interest-item">
|
||||||
|
<strong class="interest-key">{{ i18n.t().tpInterest3Key }}</strong>
|
||||||
|
<span>{{ i18n.t().tpInterest3Desc }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
OnInit,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -15,26 +8,6 @@ import { I18nService } from '../../../application/services/i18n.service';
|
||||||
styleUrl: './technical-profile.component.css',
|
styleUrl: './technical-profile.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class TechnicalProfileComponent implements OnInit {
|
export class TechnicalProfileComponent {
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
protected readonly asciiArt = signal('');
|
|
||||||
protected readonly asciiLoadError = signal(false);
|
|
||||||
protected readonly aboutParagraphs = computed(() =>
|
|
||||||
this.i18n.t().tpAboutDescription.split('\n\n').filter((p) => p.trim().length > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadTextAsset('assets/portfolio.txt')
|
|
||||||
.then((portrait) => this.asciiArt.set(portrait))
|
|
||||||
.catch(() => this.asciiLoadError.set(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadTextAsset(path: string): Promise<string> {
|
|
||||||
return fetch(path).then((response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`ASCII asset request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,9 @@
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gengar-icon {
|
.arch-icon {
|
||||||
width: 22px;
|
color: #7dcfff;
|
||||||
height: 22px;
|
font-size: 0.8125rem;
|
||||||
object-fit: contain;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.term-header-status {
|
.term-header-status {
|
||||||
|
|
@ -104,32 +101,6 @@
|
||||||
background: radial-gradient(circle at top right, rgba(122, 162, 247, 0.04) 0%, transparent 60%);
|
background: radial-gradient(circle at top right, rgba(122, 162, 247, 0.04) 0%, transparent 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Imagen pixel art + texto del sistema lado a lado */
|
|
||||||
.term-image-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
background-color: #0b0e14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.term-image {
|
|
||||||
width: 140px;
|
|
||||||
height: 140px;
|
|
||||||
object-fit: contain;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.term-banner-text {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #7dcfff;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.term-banner {
|
.term-banner {
|
||||||
margin: 0 0 var(--space-3) 0;
|
margin: 0 0 var(--space-3) 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="term-title-block">
|
<div class="term-title-block">
|
||||||
<img src="assets/images/gengar.png" alt="" class="gengar-icon" aria-hidden="true" />
|
<span class="arch-icon"></span>
|
||||||
<span class="term-window-title">bryan@archlinux: ~ (fish)</span>
|
<span class="term-window-title">bryan@archlinux: ~ (fish)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -18,12 +18,6 @@
|
||||||
<div #terminalBody class="term-body">
|
<div #terminalBody class="term-body">
|
||||||
@for (entry of entries(); track entry.id) {
|
@for (entry of entries(); track entry.id) {
|
||||||
@switch (entry.type) {
|
@switch (entry.type) {
|
||||||
@case ('image') {
|
|
||||||
<div class="term-image-banner">
|
|
||||||
<img [src]="entry.content" alt="" class="term-image" aria-hidden="true" />
|
|
||||||
<pre class="term-banner-text">{{ entry.textContent }}</pre>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('banner') {
|
@case ('banner') {
|
||||||
<pre class="term-banner" aria-label="Información del sistema Arch Linux">{{ entry.content }}</pre>
|
<pre class="term-banner" aria-label="Información del sistema Arch Linux">{{ entry.content }}</pre>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@ import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
export interface TerminalEntry {
|
export interface TerminalEntry {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly type: 'input' | 'output' | 'error' | 'success' | 'banner' | 'ascii' | 'image';
|
readonly type: 'input' | 'output' | 'error' | 'success' | 'banner' | 'ascii';
|
||||||
readonly command?: string;
|
readonly command?: string;
|
||||||
readonly content?: string;
|
readonly content?: string;
|
||||||
readonly textContent?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -45,10 +44,9 @@ export class TerminalComponent implements AfterViewInit {
|
||||||
|
|
||||||
protected readonly entries = signal<TerminalEntry[]>([
|
protected readonly entries = signal<TerminalEntry[]>([
|
||||||
{
|
{
|
||||||
id: 'init-gengar',
|
id: 'init-banner',
|
||||||
type: 'image',
|
type: 'banner',
|
||||||
content: 'assets/images/gengar.png',
|
content: this.i18n.t().terminalBanner
|
||||||
textContent: this.i18n.t().terminalBanner
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'init-hint',
|
id: 'init-hint',
|
||||||
|
|
@ -62,8 +60,8 @@ export class TerminalComponent implements AfterViewInit {
|
||||||
const t = this.i18n.t();
|
const t = this.i18n.t();
|
||||||
this.entries.update(prev =>
|
this.entries.update(prev =>
|
||||||
prev.map(e => {
|
prev.map(e => {
|
||||||
if (e.id === 'init-gengar') {
|
if (e.id === 'init-banner') {
|
||||||
return { ...e, textContent: t.terminalBanner };
|
return { ...e, content: t.terminalBanner };
|
||||||
}
|
}
|
||||||
if (e.id === 'init-hint') {
|
if (e.id === 'init-hint') {
|
||||||
return { ...e, content: t.terminalHint };
|
return { ...e, content: t.terminalHint };
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
||||||
import { Observable, throwError } from 'rxjs';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import { SecurityAgentRequest, SecurityAgentResponse } from '../domain/models/security.model';
|
|
||||||
import { SecurityAgentPort } from '../domain/ports/security-agent.port';
|
|
||||||
import { SECURITY_AGENT_WEBHOOK_URL } from '../core/tokens/security-agent-config.token';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class SecurityAgentService implements SecurityAgentPort {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
private readonly webhookUrl = inject(SECURITY_AGENT_WEBHOOK_URL);
|
|
||||||
|
|
||||||
triggerSimulation(payload: SecurityAgentRequest): Observable<SecurityAgentResponse> {
|
|
||||||
return this.http.post<SecurityAgentResponse>(this.webhookUrl, payload).pipe(
|
|
||||||
catchError((error: HttpErrorResponse) => this.handleError(error))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleError(error: HttpErrorResponse): Observable<never> {
|
|
||||||
let errorMessage = 'Ocurrió un error inesperado al conectar con el orquestador n8n.';
|
|
||||||
if (error.status === 429) {
|
|
||||||
errorMessage = 'Límite de tasa excedido (Rate Limit: máx 10 peticiones cada 5 min).';
|
|
||||||
} else if (error.status === 400) {
|
|
||||||
errorMessage = error.error?.message || 'Payload o parámetros inválidos.';
|
|
||||||
} else if (error.status === 0) {
|
|
||||||
errorMessage = 'Error de red o conexión rechazada con el servidor n8n.';
|
|
||||||
} else if (error.status >= 500) {
|
|
||||||
errorMessage = error.error?.message || `Error interno del orquestador n8n (HTTP ${error.status}).`;
|
|
||||||
}
|
|
||||||
return throwError(() => new Error(errorMessage));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 57 KiB |