From 2d875bb9f3664049f0b3283af30b789a1f182707 Mon Sep 17 00:00:00 2001 From: bryancito1090 Date: Fri, 4 Sep 2026 16:53:45 -0500 Subject: [PATCH] refactor: streamline technical profile component and remove unused hacker screen functionality feat: add incident simulator component with interactive architecture diagram and telemetry console style: update global styles for dark theme and enhance incident simulator styles --- database/schema.sql | 64 +- .../app/application/i18n/translations/en.ts | 57 +- .../app/application/i18n/translations/es.ts | 63 +- .../services/retro-audio.service.ts | 74 ++- .../app/application/stores/retro-os.store.ts | 10 +- .../src/app/domain/models/i18n.model.ts | 41 +- .../src/app/domain/models/retro-game.model.ts | 29 +- .../src/app/domain/models/retro-os.model.ts | 2 +- .../local-storage-retro-score.adapter.ts | 19 +- .../contact-section.component.css | 71 ++- .../contact-section.component.html | 15 +- .../contact-section.component.ts | 216 ++++++- .../featured-projects.component.css | 180 +++--- .../featured-projects.component.html | 56 +- .../featured-projects.component.ts | 24 +- .../project-detail.component.css | 179 +++--- .../features/hero-3d/hero-3d.component.css | 171 +++++- .../features/hero-3d/hero-3d.component.ts | 7 +- .../incident-simulator.component.css | 344 +++++++++++ .../incident-simulator.component.html | 190 ++++++ .../incident-simulator.component.ts | 154 +++++ .../features/landing/landing.component.css | 383 +++++++++--- .../features/landing/landing.component.html | 162 +++-- .../features/landing/landing.component.ts | 184 +++++- .../retro-desktop-icons.component.html | 34 +- .../retro-game-canvas.component.css | 498 +++++++++++---- .../retro-game-canvas.component.html | 210 ++++--- .../retro-game-canvas.component.ts | 576 ++++++++++-------- .../retro-scores/retro-scores.component.ts | 2 +- .../retro-start-menu.component.html | 2 +- .../features/retro-os/retro-os.component.css | 30 + .../technical-profile.component.css | 28 - .../technical-profile.component.html | 46 +- .../technical-profile.component.ts | 232 +------ src/frontend/src/styles.css | 8 +- 35 files changed, 3161 insertions(+), 1200 deletions(-) create mode 100644 src/frontend/src/app/presentation/features/incident-simulator/incident-simulator.component.css create mode 100644 src/frontend/src/app/presentation/features/incident-simulator/incident-simulator.component.html create mode 100644 src/frontend/src/app/presentation/features/incident-simulator/incident-simulator.component.ts diff --git a/database/schema.sql b/database/schema.sql index 4f017b8..928b046 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -118,24 +118,7 @@ CREATE TABLE IF NOT EXISTS `project_skills` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ------------------------------------------------------------------------------ --- 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) +-- 6. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos) -- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje -- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`) -- o el probador de API en vivo. Demuestra operaciones de escritura inmediatas @@ -153,6 +136,28 @@ CREATE TABLE IF NOT EXISTS `interactive_messages` ( KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC) ) 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) -- ============================================================================== @@ -201,15 +206,7 @@ VALUES ('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004') ON DUPLICATE KEY UPDATE `project_id` = `project_id`; --- 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 +-- 5. Mensaje de Bienvenida en el Libro de Visitas INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`) VALUES ( UUID(), @@ -218,3 +215,16 @@ VALUES ( 'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.', 'Terminal' ) 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`; + diff --git a/src/frontend/src/app/application/i18n/translations/en.ts b/src/frontend/src/app/application/i18n/translations/en.ts index c948c7c..23dbf2e 100644 --- a/src/frontend/src/app/application/i18n/translations/en.ts +++ b/src/frontend/src/app/application/i18n/translations/en.ts @@ -6,6 +6,15 @@ export const EN_TRANSLATIONS: TranslationDictionary = { switchLangAria: 'Switch to Spanish', 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', + // Hero 3D preloading: 'PRELOADING', heroAriaExperience: 'ThinkPad 3D Scroll Experience', @@ -75,7 +84,7 @@ export const EN_TRANSLATIONS: TranslationDictionary = { tpInterest3Desc: 'Active participation in tech outreach and community events (FLISOL, Google Developers Group).', // Featured Projects - fpHeaderTag: '01 // PROJECTS', + fpHeaderTag: 'PROJECTS', fpAriaLabel: "Bryan Baño's featured projects", fpPlaceholderImage: 'No screenshots', fpAltPrefix: 'Screenshot of ', @@ -132,7 +141,7 @@ export const EN_TRANSLATIONS: TranslationDictionary = { prj4Result3: 'Hardened security via JWT, database-driven role permissions, and prerequisite validation.', // Terminal & Workstation - terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION', + terminalWorkstationTag: 'INTERACTIVE WORKSTATION', terminalHeading: 'Arch Linux Terminal // Fish Shell', terminalSectionDesc: 'Full-scale interactive console to query technical ecosystem, skills, and live database.', terminalBanner: `bryan@archlinux @@ -251,27 +260,49 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d retroSetSound: 'Web Audio API Sound', retroSetSoundDesc: 'Synthetic mechanical and arcade effects', retroScoresHeader: '★ ARCADE LEADERBOARD', - retroScoresTitle: 'ZEN SNAKE // TOP 10', + retroScoresTitle: 'MINESWEEPER // TOP 10', retroScoresRefresh: 'REFRESH', retroScoresRank: '#', retroScoresTag: 'TAG', - retroScoresScore: 'SCORE', + retroScoresScore: 'TIME (S)', retroScoresDate: 'DATE', retroScoresEmpty: 'No scores recorded', - retroGameScore: 'SCORE:', + retroGameScore: 'TIME:', retroGameHigh: 'BEST:', retroGameStart: '▶ Start', retroGameRestart: 'Restart', retroGamePause: 'Pause', retroGameResume: 'Resume', - retroGameBanner: 'Press any Arrow key or click to start', - retroGameOver: 'GAME OVER!', - retroGameScoreLabel: 'Score:', + retroGameBanner: 'Click any tile to start playing', + retroGameOver: 'BOOM! GAME OVER', + retroGameScoreLabel: 'Time:', retroGameSave: 'Save', retroGameSaved: '✓ Score saved', - retroGamePlayAgain: '↺ Play Again', - retroGameUp: 'Up', - retroGameLeft: 'Left', - retroGameDown: 'Down', - retroGameRight: 'Right' + retroGameWin: 'VICTORY! MINES CLEARED', + retroGameMines: 'Mines:', + retroGameFlagMode: 'Flag Mode', + retroGameDigMode: 'Dig Mode', + + // 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.', + simBtnSimulate: 'Simulate Unauthorized IAM Access', + simBtnSimulating: 'Containing Threat in Real-Time...', + simBtnReset: 'Reset Simulation', + 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' }; diff --git a/src/frontend/src/app/application/i18n/translations/es.ts b/src/frontend/src/app/application/i18n/translations/es.ts index 8b7ecfb..c355fee 100644 --- a/src/frontend/src/app/application/i18n/translations/es.ts +++ b/src/frontend/src/app/application/i18n/translations/es.ts @@ -6,6 +6,15 @@ export const ES_TRANSLATIONS: TranslationDictionary = { switchLangAria: 'Cambiar a Inglés', 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', + // Hero 3D preloading: 'PRECARGANDO', heroAriaExperience: 'Experiencia 3D ThinkPad en Scroll', @@ -75,7 +84,7 @@ export const ES_TRANSLATIONS: TranslationDictionary = { tpInterest3Desc: 'Participación activa en eventos de difusión tecnológica (FLISOL, Google Developers Group).', // Featured Projects - fpHeaderTag: '01 // PROYECTOS', + fpHeaderTag: 'PROYECTOS', fpAriaLabel: 'Proyectos reales de Bryan Baño', fpPlaceholderImage: 'Sin capturas', fpAltPrefix: 'Captura de pantalla de ', @@ -132,7 +141,7 @@ export const ES_TRANSLATIONS: TranslationDictionary = { prj4Result3: 'Seguridad robusta con JWT, permisos por rol desde base de datos y control exhaustivo de requisitos previos.', // Terminal & Workstation - terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION', + terminalWorkstationTag: 'INTERACTIVE WORKSTATION', terminalHeading: 'Terminal Arch Linux // Fish Shell', terminalSectionDesc: 'Consola interactiva a escala completa para consultar el ecosistema técnico, habilidades y base de datos.', terminalBanner: `bryan@archlinux @@ -169,7 +178,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.", // Contact Section - contactEyebrow: '03 // CONTACTO', + contactEyebrow: 'CONTACTO', 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.', contactService1Title: 'Desarrollo Full Stack & Arquitectura Limpia', @@ -250,28 +259,50 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético retroSetScanlinesDesc: 'Líneas de barrido de monitor CRT analógico', retroSetSound: 'Sonido Web Audio API', retroSetSoundDesc: 'Efectos mecánicos y arcade sintéticos', - retroScoresHeader: '★ ARCADE LEADERBOARD', - retroScoresTitle: 'ZEN SNAKE // TOP 10', + retroScoresHeader: '★ TABLA DE RÉCORDS', + retroScoresTitle: 'BUSCAMINAS // TOP 10', retroScoresRefresh: 'ACTUALIZAR', retroScoresRank: '#', retroScoresTag: 'TAG', - retroScoresScore: 'PUNTOS', + retroScoresScore: 'TIEMPO (S)', retroScoresDate: 'FECHA', retroScoresEmpty: 'No hay puntuaciones registradas', - retroGameScore: 'PUNTOS:', - retroGameHigh: 'RÉCORD:', + retroGameScore: 'TIEMPO:', + retroGameHigh: 'MEJOR:', retroGameStart: '▶ Iniciar', retroGameRestart: 'Reiniciar', retroGamePause: 'Pausa', retroGameResume: 'Reanudar', - retroGameBanner: 'Pulsa cualquier Flecha o haz clic para empezar', - retroGameOver: '¡FIN DE LA PARTIDA!', - retroGameScoreLabel: 'Puntuación:', + retroGameBanner: 'Haz clic en una casilla para comenzar', + retroGameOver: '¡BOOM! HAS PERDIDO', + retroGameScoreLabel: 'Tiempo:', retroGameSave: 'Guardar', retroGameSaved: '✓ Récord guardado', - retroGamePlayAgain: '↺ Jugar Otra Vez', - retroGameUp: 'Arriba', - retroGameLeft: 'Izquierda', - retroGameDown: 'Abajo', - retroGameRight: 'Derecha' + retroGameWin: '¡VICTORIA! MINAS DESPEJADAS', + retroGameMines: 'Minas:', + retroGameFlagMode: 'Marcar Bandera', + retroGameDigMode: 'Descubrir', + + // 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.', + simBtnSimulate: 'Simular Acceso IAM No Autorizado', + simBtnSimulating: 'Conteniendo Amenaza en Tiempo Real...', + simBtnReset: 'Reiniciar Simulación', + 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' }; diff --git a/src/frontend/src/app/application/services/retro-audio.service.ts b/src/frontend/src/app/application/services/retro-audio.service.ts index 805a618..7432251 100644 --- a/src/frontend/src/app/application/services/retro-audio.service.ts +++ b/src/frontend/src/app/application/services/retro-audio.service.ts @@ -42,23 +42,83 @@ export class RetroAudioService { osc.stop(ctx.currentTime + 0.02); } - playSnakeEat(): void { + playCellReveal(): void { const ctx = this.getContext(); if (!ctx) return; const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(440, ctx.currentTime); - osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.08); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(520, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(780, ctx.currentTime + 0.03); - gain.gain.setValueAtTime(0.06, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08); + gain.gain.setValueAtTime(0.04, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03); osc.connect(gain); gain.connect(ctx.destination); osc.start(); - osc.stop(ctx.currentTime + 0.08); + osc.stop(ctx.currentTime + 0.03); + } + + 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 { diff --git a/src/frontend/src/app/application/stores/retro-os.store.ts b/src/frontend/src/app/application/stores/retro-os.store.ts index 0b5a613..cfdbc91 100644 --- a/src/frontend/src/app/application/stores/retro-os.store.ts +++ b/src/frontend/src/app/application/stores/retro-os.store.ts @@ -6,14 +6,14 @@ import { I18nService } from '../services/i18n.service'; const INITIAL_WINDOWS: Record = { games: { id: 'games', - title: 'Snake - Juegos de Windows XP', - icon: 'gamepad', + title: 'Buscaminas - Juegos Clásicos', + icon: 'minesweeper', isOpen: false, isMinimized: false, position: { x: 70, y: 20 }, - size: { width: 360, height: 490 }, + size: { width: 270, height: 360 }, zIndex: 10, - minSize: { width: 320, height: 380 } + minSize: { width: 260, height: 340 } }, cv: { id: 'cv', @@ -70,7 +70,7 @@ export class RetroOsStore { readonly desktopIcons = computed(() => { const t = this.i18n.t(); return [ - { id: 'icon-games', label: t.retroIconGames, windowId: 'games', iconType: 'gamepad' }, + { id: 'icon-games', label: t.retroIconGames, windowId: 'games', iconType: 'minesweeper' }, { id: 'icon-cv', label: t.retroIconCv, windowId: 'cv', iconType: 'document' }, { id: 'icon-scores', label: t.retroIconScores, windowId: 'scores', iconType: 'trophy' }, { id: 'icon-settings', label: t.retroIconSettings, windowId: 'settings', iconType: 'gear' } diff --git a/src/frontend/src/app/domain/models/i18n.model.ts b/src/frontend/src/app/domain/models/i18n.model.ts index b680168..87b073e 100644 --- a/src/frontend/src/app/domain/models/i18n.model.ts +++ b/src/frontend/src/app/domain/models/i18n.model.ts @@ -6,6 +6,15 @@ export interface TranslationDictionary { readonly switchLangAria: 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; + // Hero 3D readonly preloading: string; readonly heroAriaExperience: string; @@ -237,9 +246,31 @@ export interface TranslationDictionary { readonly retroGameScoreLabel: string; readonly retroGameSave: string; readonly retroGameSaved: string; - readonly retroGamePlayAgain: string; - readonly retroGameUp: string; - readonly retroGameLeft: string; - readonly retroGameDown: string; - readonly retroGameRight: string; + readonly retroGameWin: string; + readonly retroGameMines: string; + readonly retroGameFlagMode: string; + readonly retroGameDigMode: string; + + // Cloud Incident Simulator + readonly simTag: string; + readonly simHeading: string; + readonly simSubheading: string; + readonly simBtnSimulate: string; + readonly simBtnSimulating: string; + readonly simBtnReset: 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; } diff --git a/src/frontend/src/app/domain/models/retro-game.model.ts b/src/frontend/src/app/domain/models/retro-game.model.ts index db3b59d..9513cff 100644 --- a/src/frontend/src/app/domain/models/retro-game.model.ts +++ b/src/frontend/src/app/domain/models/retro-game.model.ts @@ -1,17 +1,14 @@ -export interface Point { - readonly x: number; - readonly y: number; +export interface MinesweeperCell { + readonly row: number; + readonly col: number; + isMine: boolean; + isRevealed: boolean; + isFlagged: boolean; + isExploded?: boolean; + adjacentMines: number; } -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 type MinesweeperFace = 'idle' | 'pressed' | 'dead' | 'won' | 'smile' | 'shock' | 'cool'; export interface RetroScore { readonly id: string; @@ -21,11 +18,3 @@ export interface RetroScore { readonly createdAtUtc: string; } -export interface GameState { - readonly score: number; - readonly highScore: number; - readonly isGameOver: boolean; - readonly isPaused: boolean; - readonly isRunning: boolean; - readonly difficulty: GameDifficulty; -} diff --git a/src/frontend/src/app/domain/models/retro-os.model.ts b/src/frontend/src/app/domain/models/retro-os.model.ts index b4211d2..cfe37cb 100644 --- a/src/frontend/src/app/domain/models/retro-os.model.ts +++ b/src/frontend/src/app/domain/models/retro-os.model.ts @@ -31,7 +31,7 @@ export interface DesktopIcon { readonly id: string; readonly label: string; readonly windowId: WindowId; - readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy'; + readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy' | 'minesweeper'; } export interface ZenOsConfig { diff --git a/src/frontend/src/app/infrastructure/adapters/local-storage-retro-score.adapter.ts b/src/frontend/src/app/infrastructure/adapters/local-storage-retro-score.adapter.ts index 8d49719..cb0a1dc 100644 --- a/src/frontend/src/app/infrastructure/adapters/local-storage-retro-score.adapter.ts +++ b/src/frontend/src/app/infrastructure/adapters/local-storage-retro-score.adapter.ts @@ -9,22 +9,22 @@ const INITIAL_SEEDS: RetroScore[] = [ { id: 'seed-1', playerTag: 'ZEN', - gameCode: 'ZEN_SNAKE', - score: 1450, + gameCode: 'MINESWEEPER', + score: 42, createdAtUtc: '2026-08-28T00:00:00.000Z' }, { id: 'seed-2', playerTag: 'NEO', - gameCode: 'ZEN_SNAKE', - score: 1120, + gameCode: 'MINESWEEPER', + score: 68, createdAtUtc: '2026-08-28T00:00:00.000Z' }, { id: 'seed-3', playerTag: 'BRY', - gameCode: 'ZEN_SNAKE', - score: 980, + gameCode: 'MINESWEEPER', + score: 95, createdAtUtc: '2026-08-28T00:00:00.000Z' } ]; @@ -54,9 +54,14 @@ export class LocalStorageRetroScoreAdapter implements RetroScorePort { getTopScores(gameCode: string, limit = 10): Observable { const scores = this.readScores(); + const isTimeBased = gameCode === 'MINESWEEPER'; const filtered = scores .filter(s => s.gameCode === gameCode) - .sort((a, b) => b.score - a.score || a.createdAtUtc.localeCompare(b.createdAtUtc)) + .sort((a, b) => + isTimeBased + ? a.score - b.score || a.createdAtUtc.localeCompare(b.createdAtUtc) + : b.score - a.score || a.createdAtUtc.localeCompare(b.createdAtUtc) + ) .slice(0, limit); return of(filtered); } diff --git a/src/frontend/src/app/presentation/features/contact-section/contact-section.component.css b/src/frontend/src/app/presentation/features/contact-section/contact-section.component.css index a65b8a8..05cd9e0 100644 --- a/src/frontend/src/app/presentation/features/contact-section/contact-section.component.css +++ b/src/frontend/src/app/presentation/features/contact-section/contact-section.component.css @@ -5,41 +5,84 @@ /* 1. SECCION PRINCIPAL — Sumi-e Background & Ma (Espacio Negativo) */ .contact-section { + position: relative; width: 100%; background-color: #121517; padding: clamp(5rem, 8vw, 7rem) 0; + overflow: hidden; +} + +.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 { - max-width: 72rem; + position: relative; + z-index: 2; + max-width: 1540px; + width: 100%; margin: 0 auto; padding: 0 clamp(1rem, 3vw, 2.5rem); + box-sizing: border-box; } -/* 2. MICRO-HEADER — Patrón de Estado Retro */ -.contact-micro-header { +/* Cabecera Normalizada de Sección */ +.contact-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-8); +} + +.contact-section-header .header-left, +.contact-section-header .title-with-badge { display: flex; align-items: center; gap: var(--space-3); - margin-bottom: var(--space-12); } -.contact-pulse-dot { - width: 6px; - height: 6px; +.contact-section-header .pulse-indicator { + width: 10px; + height: 10px; border-radius: 50%; background-color: var(--zen-accent-kintsugi); - box-shadow: 0 0 8px var(--zen-accent-kintsugi); + box-shadow: 0 0 12px var(--zen-accent-kintsugi); flex-shrink: 0; } -.contact-header-tag { - font-family: var(--font-mono); - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.14em; +.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; - color: var(--zen-text-muted); } /* 3. GRID DE CONTENIDO — 2 Columnas Responsive */ diff --git a/src/frontend/src/app/presentation/features/contact-section/contact-section.component.html b/src/frontend/src/app/presentation/features/contact-section/contact-section.component.html index 22d6b0e..570eecc 100644 --- a/src/frontend/src/app/presentation/features/contact-section/contact-section.component.html +++ b/src/frontend/src/app/presentation/features/contact-section/contact-section.component.html @@ -1,9 +1,16 @@
+ + +
- -
- - {{ i18n.t().contactEyebrow }} + +
+
+
+ +

{{ i18n.t().contactEyebrow }}

+
+
diff --git a/src/frontend/src/app/presentation/features/contact-section/contact-section.component.ts b/src/frontend/src/app/presentation/features/contact-section/contact-section.component.ts index 7beea47..471a071 100644 --- a/src/frontend/src/app/presentation/features/contact-section/contact-section.component.ts +++ b/src/frontend/src/app/presentation/features/contact-section/contact-section.component.ts @@ -1,4 +1,14 @@ -import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + signal, + inject, + ElementRef, + ViewChild, + OnInit, + AfterViewInit, + OnDestroy +} from '@angular/core'; import { ReactiveFormsModule, FormGroup, @@ -7,6 +17,20 @@ import { } from '@angular/forms'; 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({ selector: 'app-contact-section', standalone: true, @@ -15,7 +39,9 @@ import { I18nService } from '../../../application/services/i18n.service'; styleUrl: './contact-section.component.css', changeDetection: ChangeDetectionStrategy.OnPush }) -export class ContactSectionComponent { +export class ContactSectionComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('rainCanvas') private readonly rainCanvas?: ElementRef; + protected readonly i18n = inject(I18nService); // Estado reactivo del formulario con Signals @@ -45,6 +71,191 @@ export class ContactSectionComponent { }) }); + 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 { if (this.contactForm.invalid) { this.contactForm.markAllAsTouched(); @@ -55,7 +266,6 @@ export class ContactSectionComponent { this.errorMessage.set(null); this.isSubmitting.set(true); - // Simulación de envío con confirmación visual inmediata setTimeout(() => { this.isSubmitting.set(false); this.isSuccess.set(true); diff --git a/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.css b/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.css index 79eb2e4..f02cf17 100644 --- a/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.css +++ b/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.css @@ -2,7 +2,6 @@ :host { display: block; width: 100%; - height: 100%; } .featured-projects-wrapper { @@ -13,94 +12,96 @@ user-select: text; } -.section-micro-header { +/* Cabecera limpia y abierta de la sección de Proyectos */ +.projects-section-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); + 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-2); + gap: var(--space-3); } -.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; -} - -.carousel-nav-controls { +.title-with-badge { display: flex; align-items: center; gap: var(--space-3); } -.carousel-counter { - font-family: var(--font-mono); - font-size: 0.8125rem; - font-weight: 700; - color: var(--zen-text-secondary); - letter-spacing: 0.1em; +.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; } -.carousel-buttons { +.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: 6px; + gap: var(--space-2); } .carousel-nav-btn { display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + 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); - font-size: 1.15rem; - line-height: 1; cursor: pointer; transition: all var(--transition-fast); } -.carousel-nav-btn:hover:not(:disabled) { +.carousel-nav-btn:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--zen-accent-kintsugi); - transform: scale(1.06); + color: var(--zen-accent-kintsugi); + transform: scale(1.08); + box-shadow: 0 0 12px rgba(224, 175, 104, 0.2); } -.carousel-nav-btn:disabled { - opacity: 0.28; - cursor: not-allowed; +.carousel-nav-btn:active { + transform: scale(0.95); } -/* Cuadrícula de Proyectos (Grid limpio sin scroll horizontal) */ +/* Cuadrícula de Proyectos en Carrusel (Ocupa 100% de ancho) */ .projects-grid { display: grid; grid-template-columns: 1fr; - gap: var(--space-4); + 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)); } @@ -112,13 +113,14 @@ align-items: stretch; gap: var(--space-3); padding: var(--space-4); + width: 100%; + box-sizing: border-box; background: - linear-gradient(180deg, rgba(16, 20, 25, 0.92), rgba(12, 16, 20, 0.88)), - rgba(11, 15, 18, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); - border-top: 3px solid var(--prj-accent, var(--zen-accent-kintsugi)); - border-radius: 1.15rem; - box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18); + 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); @@ -138,23 +140,23 @@ .project-card:hover, .project-card:focus-visible { - transform: translateY(-5px); - border-color: var(--prj-accent, var(--zen-accent-kintsugi)); - box-shadow: 0 16px 28px rgba(0, 0, 0, 0.24), 0 0 18px rgba(125, 207, 255, 0.1); + 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(--prj-accent, var(--zen-accent-kintsugi)); - box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28), 0 0 22px rgba(125, 207, 255, 0.12); + 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%; - height: 180px; - background-color: #090b0e; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 0.9rem; + 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; @@ -163,8 +165,7 @@ } .project-image-slot.no-image { - background: #080c12; - border-color: rgba(125, 207, 255, 0.18); + background: #080c14; } .tech-blueprint-slot { @@ -176,7 +177,7 @@ height: 100%; background: radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%), - #0a0e16; + #080b12; user-select: none; } @@ -203,7 +204,7 @@ .blueprint-icon { font-size: 1.8rem; - color: var(--prj-accent, var(--zen-accent-kintsugi)); + color: var(--zen-accent-kintsugi); line-height: 1; } @@ -212,7 +213,7 @@ font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; - color: var(--prj-accent, var(--zen-accent-kintsugi)); + color: var(--zen-accent-kintsugi); } .blueprint-title { @@ -269,7 +270,6 @@ height: 100%; object-fit: cover; display: block; - filter: saturate(1.08) contrast(1.04); } .project-content { @@ -277,7 +277,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: 0.8rem; + gap: 0.75rem; } .project-meta-row { @@ -296,7 +296,7 @@ .meta-bullet { font-size: 0.5rem; - color: var(--prj-accent, var(--zen-accent-kintsugi)); + color: var(--zen-accent-kintsugi); } .meta-institution { @@ -318,17 +318,17 @@ .project-title { font-family: var(--font-sans); - font-size: 1.45rem; + font-size: 1.35rem; font-weight: 800; color: var(--zen-text-primary); margin: 0; - line-height: 1.2; + line-height: 1.25; letter-spacing: -0.01em; } .project-impact { font-family: var(--font-sans); - font-size: 0.82rem; + font-size: 0.8125rem; color: var(--zen-text-secondary); line-height: 1.6; margin: 0; @@ -343,6 +343,7 @@ flex-wrap: wrap; gap: 0.45rem; margin-top: auto; + padding-top: 0.25rem; } .tech-chip { @@ -352,11 +353,11 @@ font-family: var(--font-sans); font-size: 0.67rem; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.03em; background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--zen-border-subtle); border-radius: 999px; - color: var(--zen-text-muted); + color: var(--zen-text-secondary); } .project-action { @@ -364,32 +365,39 @@ align-items: center; justify-content: flex-start; margin-top: 0.25rem; - font-size: 0.72rem; + font-size: 0.75rem; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.06em; text-transform: uppercase; - color: var(--prj-accent, var(--zen-accent-kintsugi)); + 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: 100; + z-index: 1000; display: flex; align-items: center; justify-content: center; padding: clamp(1rem, 2vw, 2.5rem); - background: rgba(4, 7, 10, 0.82); - backdrop-filter: blur(10px); + background: rgba(5, 7, 10, 0.85); + backdrop-filter: blur(12px); } .project-detail-shell { - width: min(1200px, 100%); - max-height: 92vh; + width: min(1300px, 100%); + max-height: 90vh; overflow: auto; - border-radius: 1.4rem; + border-radius: var(--zen-radius-md); } +/* Dots Indicadores */ .carousel-dots-row { display: flex; align-items: center; @@ -427,19 +435,15 @@ .carousel-dot.is-active .dot-inner { width: 24px; border-radius: 999px; - background: var(--prj-accent, var(--zen-accent-kintsugi)); - box-shadow: 0 0 10px var(--prj-accent, var(--zen-accent-kintsugi)); + background: var(--zen-accent-kintsugi); + box-shadow: 0 0 10px var(--zen-accent-kintsugi); } -@media (max-width: 540px) { +@media (max-width: 640px) { .project-card { padding: var(--space-3); } - .project-image-slot { - height: 150px; - } - .project-meta-row { flex-direction: column; align-items: flex-start; diff --git a/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.html b/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.html index bf87e79..4c1b32a 100644 --- a/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.html +++ b/src/frontend/src/app/presentation/features/featured-projects/featured-projects/featured-projects.component.html @@ -1,44 +1,46 @@ +
diff --git a/src/frontend/src/app/presentation/features/incident-simulator/incident-simulator.component.ts b/src/frontend/src/app/presentation/features/incident-simulator/incident-simulator.component.ts new file mode 100644 index 0000000..7838166 --- /dev/null +++ b/src/frontend/src/app/presentation/features/incident-simulator/incident-simulator.component.ts @@ -0,0 +1,154 @@ +import { Component, ChangeDetectionStrategy, signal, computed, inject, OnDestroy, OnInit } from '@angular/core'; +import { I18nService } from '../../../application/services/i18n.service'; +import { RetroAudioService } from '../../../application/services/retro-audio.service'; + +export type SimulationStage = 'idle' | 'breach' | 'detection' | 'remediation' | 'notification' | 'contained'; + +export interface SecOpsLog { + readonly timestamp: string; + readonly level: 'WARN' | 'INFO' | 'ACTION' | 'SUCCESS'; + readonly tag: string; + readonly message: string; +} + +@Component({ + selector: 'app-incident-simulator', + standalone: true, + templateUrl: './incident-simulator.component.html', + styleUrl: './incident-simulator.component.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class IncidentSimulatorComponent implements OnInit, OnDestroy { + protected readonly i18n = inject(I18nService); + private readonly audio = inject(RetroAudioService); + + protected readonly stage = signal('idle'); + protected readonly logs = signal([]); + protected readonly threatIp = signal('198.51.100.42'); + protected readonly executionTimeMs = signal(184); + + protected readonly isSimulating = computed(() => { + const s = this.stage(); + return s === 'breach' || s === 'detection' || s === 'remediation' || s === 'notification'; + }); + + protected readonly isContained = computed(() => this.stage() === 'contained'); + + private timeoutIds: ReturnType[] = []; + + ngOnInit(): void { + this.initDefaultLogs(); + } + + private initDefaultLogs(): void { + const time = this.getCurrentTimestamp(); + this.logs.set([ + { + timestamp: time, + level: 'INFO', + tag: 'GUARDDUTY', + message: 'Cloud perimeter telemetry nominal. 0 active security findings in us-east-1.' + }, + { + timestamp: time, + level: 'INFO', + tag: 'EVENTBRIDGE', + message: 'Active rule pattern matched: Rule-SecOps-IAM-AutoRemediation [ENABLED].' + } + ]); + } + + startSimulation(): void { + if (this.isSimulating()) return; + + this.clearTimeouts(); + this.audio.playClick(); + this.logs.set([]); + + // Fase 1: Intento de Acceso No Autorizado (Vector de Amenaza) + this.stage.set('breach'); + this.appendLog({ + level: 'WARN', + tag: 'IAM-ALERT', + message: `Anomalous API request: iam:CreateAccessKey from untrusted IP ${this.threatIp()} for principal "svc-deployer".` + }); + + // Fase 2: Captura en CloudTrail & Enrutamiento en EventBridge (después de 700ms) + const t1 = setTimeout(() => { + this.stage.set('detection'); + this.audio.playCellReveal(); + this.appendLog({ + level: 'INFO', + tag: 'CLOUDTRAIL', + message: 'Management event ingested in us-east-1. EventBridge rule "SecOps-AutoRemediate" triggered synchronously.' + }); + }, 700); + this.timeoutIds.push(t1); + + // Fase 3: Remediación Automatizada en AWS Lambda (después de 1500ms) + const t2 = setTimeout(() => { + this.stage.set('remediation'); + this.audio.playFlagToggle(); + this.appendLog({ + level: 'ACTION', + tag: 'LAMBDA', + message: 'Executing fn-iam-incident-response: Revoking active STS sessions & attaching inline DenyAllSecurityPolicy.' + }); + }, 1500); + this.timeoutIds.push(t2); + + // Fase 4: Despacho de Notificación Amazon SES (después de 2300ms) + const t3 = setTimeout(() => { + this.stage.set('notification'); + this.audio.playCellReveal(); + this.appendLog({ + level: 'INFO', + tag: 'SES-ALERT', + message: 'Dispatching cryptographic audit report to secops-team@bryan.dev via Amazon SES.' + }); + }, 2300); + this.timeoutIds.push(t3); + + // Fase 5: Amenaza Contenida con Éxito (después de 3100ms) + const t4 = setTimeout(() => { + this.stage.set('contained'); + this.audio.playVictory(); + this.appendLog({ + level: 'SUCCESS', + tag: 'CONTAINED', + message: `Threat neutralized successfully in ${this.executionTimeMs()}ms. Zero credentials compromised. State: SECURE.` + }); + }, 3100); + this.timeoutIds.push(t4); + } + + resetSimulation(): void { + this.clearTimeouts(); + this.audio.playClick(); + this.stage.set('idle'); + this.initDefaultLogs(); + } + + private appendLog(entry: Omit): void { + const timestamp = this.getCurrentTimestamp(); + this.logs.update(prev => [...prev, { ...entry, timestamp }]); + } + + private getCurrentTimestamp(): string { + const now = new Date(); + const h = String(now.getHours()).padStart(2, '0'); + const m = String(now.getMinutes()).padStart(2, '0'); + const s = String(now.getSeconds()).padStart(2, '0'); + const ms = String(now.getMilliseconds()).padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; + } + + private clearTimeouts(): void { + this.timeoutIds.forEach(id => clearTimeout(id)); + this.timeoutIds = []; + } + + ngOnDestroy(): void { + this.clearTimeouts(); + } +} diff --git a/src/frontend/src/app/presentation/features/landing/landing.component.css b/src/frontend/src/app/presentation/features/landing/landing.component.css index b0b6575..a7774df 100644 --- a/src/frontend/src/app/presentation/features/landing/landing.component.css +++ b/src/frontend/src/app/presentation/features/landing/landing.component.css @@ -2,7 +2,7 @@ display: block; width: 100%; min-height: 100vh; - background-color: var(--zen-bg-base); + background-color: #121517; color: var(--zen-text-primary); } @@ -11,7 +11,7 @@ flex-direction: column; min-height: 100vh; position: relative; - background-color: var(--zen-bg-base); + background-color: #121517; } /* Header Minimalista Flotante */ @@ -25,28 +25,81 @@ align-items: center; justify-content: space-between; height: 56px; - padding: 0 clamp(1rem, 3vw, 2.5rem); - background: rgba(10, 10, 11, 0.75); - backdrop-filter: blur(12px) saturate(160%); + padding: 0 clamp(0.75rem, 2vw, 2rem); + background: rgba(10, 10, 11, 0.85); + backdrop-filter: blur(14px) saturate(160%); border-bottom: 1px solid var(--zen-border-subtle); + gap: var(--space-3); } -.brand-block { +.brand-block-btn { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; display: flex; align-items: center; + flex-shrink: 0; } .brand-logo { - height: 32px; + 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-size: 0.8125rem; + font-weight: 600; + padding: 5px 10px; + border-radius: var(--zen-radius-xs); + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.nav-item:hover { + 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 { display: flex; align-items: center; - gap: var(--space-4); + gap: var(--space-3); + flex-shrink: 0; } .time-location-block { @@ -59,14 +112,14 @@ } .live-clock { - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 600; color: var(--zen-text-primary); letter-spacing: 0.02em; } .location-label { - font-size: 0.75rem; + font-size: 0.6875rem; color: var(--zen-text-muted); } @@ -78,7 +131,7 @@ background: var(--zen-bg-surface); border: 1px solid var(--zen-border-subtle); border-radius: var(--zen-radius-xs); - padding: 4px 10px; + padding: 4px 8px; cursor: pointer; transition: var(--transition-fast); } @@ -89,7 +142,112 @@ background: var(--zen-bg-elevated); } -.hero-container { width: 100%; } +.hero-container { + 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; +} + +.hero-container.isolated-view { + padding-top: 0; + background-color: #000000; +} + +.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) { + .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) @@ -97,10 +255,9 @@ .interactive-stage { width: 100%; - padding: var(--space-16) clamp(1rem, 3vw, 2.5rem) var(--space-16) clamp(1rem, 3vw, 2.5rem); + padding: var(--space-16) clamp(1rem, 3vw, 2.5rem) var(--space-12) clamp(1rem, 3vw, 2.5rem); background-color: #121517; background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12)); - scroll-margin-top: 40px; } .stage-grid { @@ -145,46 +302,82 @@ height: 100%; } -.matrix-stage { - width: 100%; - height: clamp(64px, 10vh, 104px); - background: #121517; -} - -.matrix-stage app-technical-profile { - display: block; - width: 100%; - height: 100%; -} - /* ============================================================================== - SECCIÓN DE PROYECTOS DESTACADOS (CASOS REALES A ANCHO COMPLETO) + SECCIÓN DE PROYECTOS DESTACADOS ============================================================================== */ .projects-stage { width: 100%; padding: var(--space-16) clamp(1rem, 3vw, 2.5rem); background-color: #121517; - border-top: 1px solid rgba(255, 255, 255, 0.04); + box-sizing: border-box; +} + +.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 { width: 100%; + max-width: 1540px; + margin: 0 auto; } /* ============================================================================== - SECCIÓN DEDICADA: TERMINAL ARCH LINUX (AMPLIA Y CONFORTABLE) + SECCIÓN: SIMULADOR DE RESPUESTA A INCIDENTES CLOUD + ============================================================================== */ + +.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 { width: 100%; padding: var(--space-16) clamp(1rem, 3vw, 2.5rem); background-color: #121517; - border-top: 1px solid rgba(255, 255, 255, 0.04); + box-sizing: border-box; +} + +.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 { - max-width: 1040px; + max-width: 1540px; margin: 0 auto; width: 100%; display: flex; @@ -192,62 +385,90 @@ gap: var(--space-4); } -.terminal-header-meta { - display: flex; - flex-direction: column; - gap: 4px; -} - -.mono-section-tag { - font-family: var(--font-sans); - font-size: 0.75rem; - font-weight: 700; - color: var(--zen-accent-kintsugi); - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.terminal-section-heading { - font-family: var(--font-sans); - font-size: clamp(1.75rem, 3vw, 2.25rem); - font-weight: 800; - color: var(--zen-text-primary); - margin: 0; - letter-spacing: -0.02em; -} - -.terminal-section-desc { - font-family: var(--font-sans); - font-size: 0.9375rem; - color: var(--zen-text-secondary); - margin: 0; - max-width: 680px; - line-height: 1.6; -} - -/* Footer Minimalista */ -.zen-footer { - margin-top: auto; +/* Cabecera Normalizada de Sección */ +.terminal-section-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-6) clamp(1rem, 3vw, 2.5rem); - border-top: 1px solid var(--zen-border-subtle); + 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, +.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-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; +} + +/* ============================================================================== + SECCIÓN: CONTACTO + ============================================================================== */ + +.contact-stage { + width: 100%; background-color: #121517; } -.footer-copy { - font-family: var(--font-sans); - font-size: 0.8125rem; - color: var(--zen-text-muted); +.contact-stage.isolated-view { + min-height: calc(100vh - 56px); + padding-top: 56px; + background-color: #121517; } -.footer-version { - font-family: var(--font-sans); - font-size: 0.75rem; - font-weight: 600; - color: var(--zen-accent-matcha); - background: rgba(158, 206, 106, 0.08); - padding: 2px 6px; - border-radius: var(--zen-radius-xs); +@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; + } } diff --git a/src/frontend/src/app/presentation/features/landing/landing.component.html b/src/frontend/src/app/presentation/features/landing/landing.component.html index c5a6d92..2208c1b 100644 --- a/src/frontend/src/app/presentation/features/landing/landing.component.html +++ b/src/frontend/src/app/presentation/features/landing/landing.component.html @@ -1,9 +1,72 @@
-
+
+ + + +
@@ -19,56 +82,67 @@
-
- -
+ @if (activeSection() === 'hero') { +
+ +
+ } -
-
- -
- -
+ @if (activeSection() === 'retro') { +
+
+ +
+ +
- -
- -
-
-
- -
- -
+ +
+ +
+
+
+ } -
-
- -
-
+ @if (activeSection() === 'projects') { +
+
+ +
+
+ } + + + @if (activeSection() === 'simulator') { +
+ +
+ } -
-
-
- -

{{ i18n.t().terminalHeading }}

-

- {{ i18n.t().terminalSectionDesc }} -

+ @if (activeSection() === 'terminal') { +
+
+ +
+
+
+ +

{{ i18n.t().navTerminal }}

+
+
+
+
- -
-
+ + } - - - -
- - v1.0.0-alpha -
+ @if (activeSection() === 'contact') { +
+ +
+ }
\ No newline at end of file diff --git a/src/frontend/src/app/presentation/features/landing/landing.component.ts b/src/frontend/src/app/presentation/features/landing/landing.component.ts index 92a2ca0..c127491 100644 --- a/src/frontend/src/app/presentation/features/landing/landing.component.ts +++ b/src/frontend/src/app/presentation/features/landing/landing.component.ts @@ -1,12 +1,24 @@ -import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, inject, ViewChild, ElementRef } from '@angular/core'; import { Hero3dComponent } from '../hero-3d/hero-3d.component'; import { RetroOsComponent } from '../retro-os/retro-os.component'; import { TerminalComponent } from '../terminal/terminal.component'; import { FeaturedProjectsComponent } from '../featured-projects/featured-projects/featured-projects.component'; import { ContactSectionComponent } from '../contact-section/contact-section.component'; import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component'; +import { IncidentSimulatorComponent } from '../incident-simulator/incident-simulator.component'; 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({ selector: 'app-landing', standalone: true, @@ -16,7 +28,8 @@ import { I18nService } from '../../../application/services/i18n.service'; TerminalComponent, FeaturedProjectsComponent, ContactSectionComponent, - TechnicalProfileComponent + TechnicalProfileComponent, + IncidentSimulatorComponent ], templateUrl: './landing.component.html', styleUrl: './landing.component.css', @@ -26,6 +39,7 @@ export class LandingComponent implements OnInit, OnDestroy { protected readonly i18n = inject(I18nService); protected readonly currentYear = signal(new Date().getFullYear()); protected readonly currentTime = signal(''); + protected readonly activeSection = signal('hero'); private clockInterval: ReturnType | null = null; private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', { @@ -36,9 +50,167 @@ export class LandingComponent implements OnInit, OnDestroy { hour12: false }); + @ViewChild('navContainer') private navContainerRef?: ElementRef; + + private isTransitioning = false; + private touchStartY = 0; + private touchStartX = 0; + private wheelDeltaAccum = 0; + private wheelResetTimer: ReturnType | null = null; + ngOnInit(): void { this.updateClock(); this.clockInterval = setInterval(() => this.updateClock(), 1000); + this.parseInitialHash(); + this.setupScrollTransitions(); + } + + setSection(section: LandingSection, entryPosition: 'top' | 'bottom' = 'top'): void { + if (this.activeSection() === section && this.isTransitioning) return; + this.isTransitioning = true; + setTimeout(() => { + this.isTransitioning = false; + }, 750); + + 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); + + setTimeout(() => { + 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' }); + } + }, 30); + } + } + + 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) 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 (e.touches.length > 0) { + this.touchStartY = e.touches[0].clientY; + this.touchStartX = e.touches[0].clientX; + } + }; + + private onTouchEnd = (e: TouchEvent): void => { + if (this.isTransitioning || 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(); + } + } + }; + + private 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'); + } + } + + private 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 { @@ -49,5 +221,13 @@ export class LandingComponent implements OnInit, OnDestroy { if (this.clockInterval !== null) { clearInterval(this.clockInterval); } + if (this.wheelResetTimer !== null) { + clearTimeout(this.wheelResetTimer); + } + if (typeof window !== 'undefined') { + window.removeEventListener('wheel', this.onWheel); + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchend', this.onTouchEnd); + } } } diff --git a/src/frontend/src/app/presentation/features/retro-os/components/retro-desktop-icons/retro-desktop-icons.component.html b/src/frontend/src/app/presentation/features/retro-os/components/retro-desktop-icons/retro-desktop-icons.component.html index ca41928..d6ac13f 100644 --- a/src/frontend/src/app/presentation/features/retro-os/components/retro-desktop-icons/retro-desktop-icons.component.html +++ b/src/frontend/src/app/presentation/features/retro-os/components/retro-desktop-icons/retro-desktop-icons.component.html @@ -8,8 +8,40 @@ >
@switch (icon.iconType) { + @case ('minesweeper') { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } @case ('gamepad') { - + diff --git a/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.css b/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.css index 02a8dbe..a3a3b81 100644 --- a/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.css +++ b/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.css @@ -1,139 +1,417 @@ :host { - display: block; - width: 100%; - height: 100%; - background: #ece9d8; - user-select: none; - font-family: var(--font-sans); -} - -.xp-game-wrapper { display: flex; flex-direction: column; align-items: center; - padding: 6px; - gap: 6px; + 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; } -/* Barra Superior de Estadísticas y Acciones */ -.xp-game-header { +.xp-minesweeper-window { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; width: 100%; - max-width: 300px; + 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: #f0ede0; - border: 1px solid #7f9db9; - border-radius: 3px; - padding: 4px 8px; - box-shadow: inset 0 1px 0 #ffffff; + 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; } -.xp-hud-stats { display: flex; gap: 10px; } -.hud-item { display: flex; align-items: center; gap: 4px; } -.hud-tag { font-size: 0.625rem; font-weight: 700; color: #333333; } -.hud-num { font-size: 0.8125rem; font-weight: 700; color: #0055ea; } -.hud-num.highlight { color: #d83a17; } -.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 { +/* Contadores LED Digitales (7 Segmentos) */ +.xp-led-display { position: relative; - width: 300px; - height: 300px; - border: 2px inset #7f9db9; - border-radius: 2px; - background: #1c2833; - cursor: pointer; - box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.4); + 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; } -.xp-snake-canvas { width: 300px; height: 300px; display: block; } - -/* Banner de Instrucción en la parte inferior del canvas */ -.xp-start-banner { +.led-ghost { 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); + top: 1px; + right: 4px; + color: #380000; pointer-events: none; + z-index: 1; } -/* 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); +.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; +} + +/* 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; - 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; } +.xp-ms-row { + display: flex; +} -/* 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; +/* 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; - box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0, 0, 0, 0.15); + 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-arrow-btn:hover { border-color: #f29536; background: linear-gradient(180deg, #fff7ea 0%, #ffdca8 100%); } -.xp-arrow-btn:active { background: #d8d4c2; transform: translateY(1px); } -.xp-arrow-btn.up { grid-column: 2; grid-row: 1; } -.xp-arrow-btn.left { grid-column: 1; grid-row: 2; } -.xp-arrow-btn.down { grid-column: 2; grid-row: 2; } -.xp-arrow-btn.right { grid-column: 3; grid-row: 2; } + +.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%; + max-width: 236px; + 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; + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + 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-dialog-titlebar { + background: linear-gradient(90deg, #0055ea 0%, #3f8cf3 100%); + color: #ffffff; + font-size: 0.6875rem; + font-weight: 700; + padding: 2px 4px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.xp-dialog-btn-close { + width: 14px; + height: 14px; + background: #d13a17; + color: #ffffff; + border: 1px solid #ffffff; + border-radius: 2px; + font-size: 0.5625rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; +} + +.xp-dialog-body { + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.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; +} + diff --git a/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.html b/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.html index 3f417e8..e516f7f 100644 --- a/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.html +++ b/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.html @@ -1,89 +1,153 @@ -
- -
-
-
- {{ i18n.t().retroGameScore }} - {{ score() }} +
+ +
+ +
+ +
+ 888 + {{ formattedMines() }}
-
- {{ i18n.t().retroGameHigh }} - {{ highScore() }} -
-
-
+ - -
-
- -
- - - - @if (!isPlaying() && !isGameOver()) { -
- {{ i18n.t().retroGameBanner }} + +
+ 888 + {{ formattedTime() }}
- } + - - @if (isGameOver()) { -
- {{ i18n.t().retroGameOver }} - {{ i18n.t().retroGameScoreLabel }} {{ score() }} - - @if (!isScoreSaved() && score() > 0) { -
- - + +
+
+ @for (row of grid(); track $index; let r = $index) { +
+ @for (cell of row; track cell.col; let c = $index) { + + }
} - - @if (isScoreSaved()) { - {{ i18n.t().retroGameSaved }} - } - -
- } +
- -
-
- - - - -
+ +
+ + @if (bestTime() !== null) { +
+ ★ {{ bestTime() }}s +
+ }
+ + + @if (showWinDialog()) { +
+
+ +
+ Buscaminas + +
+ + +
+
+ 🏆 +
+

¡Enhorabuena!

+

Has despejado el campo en {{ elapsedSeconds() }} segundos.

+
+ + +
+
+
+ + +
+ + +
+
+
+
+ }
diff --git a/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.ts b/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.ts index df8e1ca..7d0473f 100644 --- a/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.ts +++ b/src/frontend/src/app/presentation/features/retro-os/components/retro-game-canvas/retro-game-canvas.component.ts @@ -1,22 +1,21 @@ import { Component, - AfterViewInit, + OnInit, OnDestroy, - ElementRef, - viewChild, ChangeDetectionStrategy, signal, + computed, inject, HostListener } from '@angular/core'; import { RetroAudioService } from '../../../../../application/services/retro-audio.service'; import { RetroScorePort } from '../../../../../domain/ports/retro-score.port'; -import { Direction, Point } from '../../../../../domain/models/retro-game.model'; +import { MinesweeperCell, MinesweeperFace } from '../../../../../domain/models/retro-game.model'; import { I18nService } from '../../../../../application/services/i18n.service'; -const GRID_SIZE = 15; -const CELL_SIZE = 20; // 15 * 20 = 300px -const TICK_RATE_MS = 110; +const ROWS = 9; +const COLS = 9; +const TOTAL_MINES = 10; @Component({ selector: 'app-retro-game-canvas', @@ -25,332 +24,395 @@ const TICK_RATE_MS = 110; styleUrl: './retro-game-canvas.component.css', changeDetection: ChangeDetectionStrategy.OnPush }) -export class RetroGameCanvasComponent implements AfterViewInit, OnDestroy { +export class RetroGameCanvasComponent implements OnInit, OnDestroy { private readonly audio = inject(RetroAudioService); private readonly scorePort = inject(RetroScorePort); protected readonly i18n = inject(I18nService); - protected readonly canvasRef = viewChild>('gameCanvas'); - private ctx: CanvasRenderingContext2D | null = null; - - protected readonly score = signal(0); - protected readonly highScore = signal(0); - protected readonly isGameOver = signal(false); - protected readonly isPaused = signal(false); + // Estados del juego con Angular Signals + protected readonly grid = signal([]); protected readonly isPlaying = signal(false); + protected readonly isGameOver = signal(false); + protected readonly isWon = signal(false); + protected readonly face = signal('idle'); + protected readonly elapsedSeconds = signal(0); + protected readonly bestTime = signal(null); + protected readonly isFlagMode = signal(false); // Para dispositivos táctiles protected readonly playerTag = signal('XP'); protected readonly isScoreSaved = signal(false); + protected readonly showWinDialog = signal(false); - private snake: Point[] = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }]; - private direction: Direction = 'UP'; - private nextDirection: Direction = 'UP'; - private food: Point = { x: 7, y: 3 }; + private timerInterval: ReturnType | null = null; + private minesPlaced = false; - private animationFrameId: number | null = null; - private lastTickTime = 0; - - ngAfterViewInit(): void { - const canvas = this.canvasRef()?.nativeElement; - if (!canvas) return; - this.ctx = canvas.getContext('2d'); - if (this.ctx) { - this.ctx.imageSmoothingEnabled = false; + // Contador de minas restantes (Minas totales - banderas colocadas) + protected readonly remainingMines = computed(() => { + let flags = 0; + const currentGrid = this.grid(); + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (currentGrid[r]?.[c]?.isFlagged) { + flags++; + } + } } - this.loadHighScore(); - // Dibujar inmediatamente el tablero con la serpiente y la manzana 100% visibles - this.resetState(); - this.render(); + return TOTAL_MINES - flags; + }); + + // Contador de minas en formato clásico 3 dígitos LED (ej: "010", "009", "-01") + protected readonly formattedMines = computed(() => { + 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(() => { + const sec = this.elapsedSeconds(); + return Math.min(sec, 999).toString().padStart(3, '0'); + }); + + ngOnInit(): void { + this.loadBestTime(); + this.resetGame(); } - resetState(): void { - this.snake = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }]; - this.direction = 'UP'; - this.nextDirection = 'UP'; - this.food = { x: 7, y: 3 }; - this.score.set(0); - this.isGameOver.set(false); - this.isPaused.set(false); + resetGame(): void { + this.stopTimer(); + this.minesPlaced = false; this.isPlaying.set(false); + this.isGameOver.set(false); + this.isWon.set(false); + this.showWinDialog.set(false); + this.face.set('idle'); + this.elapsedSeconds.set(0); this.isScoreSaved.set(false); - } - startGame(): void { - if (this.animationFrameId !== null) { - cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = null; + 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.resetState(); - this.spawnFood(); - this.isPlaying.set(true); - - this.audio.playClick(); - this.lastTickTime = performance.now(); - this.loop(performance.now()); + this.grid.set(newGrid); } - onCanvasClick(): void { - if (!this.isPlaying()) { - this.startGame(); - } else if (this.isGameOver()) { - this.startGame(); + onFaceClick(): void { + this.audio.playClick(); + this.resetGame(); + } + + onFaceMouseDown(): void { + if (!this.isGameOver() && !this.isWon()) { + this.face.set('pressed'); } } - togglePause(): void { - if (!this.isPlaying() || this.isGameOver()) return; - this.isPaused.update(p => !p); - this.audio.playClick(); + onFaceMouseUp(): void { + if (!this.isGameOver() && !this.isWon()) { + this.face.set('idle'); + } } - @HostListener('window:keydown', ['$event']) - handleKeyDown(event: KeyboardEvent): void { - const validKeys = [ - 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'w', 's', 'a', 'd', 'W', 'S', 'A', 'D', ' ', 'Enter' - ]; + toggleFlagMode(): void { + this.audio.playClick(); + this.isFlagMode.update(v => !v); + } - if (!validKeys.includes(event.key)) return; + onCellClick(row: number, col: number): void { + if (this.isGameOver() || this.isWon()) { + this.audio.playClick(); + this.resetGame(); + return; + } - // Prevenir el scroll del navegador con las flechas + if (this.isFlagMode()) { + this.toggleFlag(row, col); + return; + } + + this.revealCell(row, col); + } + + onCellRightClick(event: MouseEvent, row: number, col: number): void { event.preventDefault(); + if (this.isGameOver() || this.isWon()) return; + this.toggleFlag(row, col); + } - // Si aún no está jugando, iniciar inmediatamente al pulsar cualquier flecha o tecla de control - if (!this.isPlaying() || this.isGameOver()) { - 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'; - if (event.key === 'ArrowRight' || event.key === 'd' || event.key === 'D') this.nextDirection = 'RIGHT'; - return; - } + onCellMouseDown(cell: MinesweeperCell): void { + if (this.isGameOver() || this.isWon() || cell.isRevealed || cell.isFlagged) return; + this.face.set('pressed'); + } - switch (event.key) { - case 'ArrowUp': - case 'w': - case 'W': - if (this.direction !== 'DOWN') this.nextDirection = 'UP'; - 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; + @HostListener('window:mouseup') + onGlobalMouseUp(): void { + if (!this.isGameOver() && !this.isWon()) { + this.face.set('idle'); } } - setDirection(newDir: Direction): void { - if (!this.isPlaying() || this.isGameOver()) { - this.startGame(); - this.nextDirection = newDir; - return; + // 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); + } + }); } - 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; + private startTimer(): void { + this.stopTimer(); + this.timerInterval = setInterval(() => { + this.elapsedSeconds.update(s => Math.min(s + 1, 999)); + }, 1000); + } - if (!this.isPaused() && !this.isGameOver()) { - if (timestamp - this.lastTickTime >= TICK_RATE_MS) { - this.update(); - this.lastTickTime = timestamp; + 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++; } } - 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; - } - - // Choque con el propio cuerpo - if (this.snake.some(segment => segment.x === head.x && segment.y === head.y)) { - this.triggerGameOver(); - return; - } - - this.snake.unshift(head); - - // Comer fruta - if (head.x === this.food.x && head.y === this.food.y) { - const nextScore = this.score() + 10; - this.score.set(nextScore); - if (nextScore > this.highScore()) { - this.highScore.set(nextScore); + // 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; + } + + // Si hace clic en una mina: GAME OVER + if (cell.isMine) { + cell.isRevealed = true; + cell.isExploded = true; + this.grid.set(currentGrid); + this.handleGameOver(row, col); + return; + } + + // Casilla segura: descubrir con flood-fill si está vacía + this.floodFill(currentGrid, row, col); + this.grid.set(currentGrid); + this.audio.playCellReveal(); + this.checkWinCondition(currentGrid); + } + + private floodFill(grid: MinesweeperCell[][], row: number, col: number): void { + const cell = grid[row]?.[col]; + if (!cell || cell.isRevealed || cell.isFlagged || cell.isMine) return; + + cell.isRevealed = true; + + // Si no tiene minas adyacentes (casilla vacía '0'), expandir en cascada + if (cell.adjacentMines === 0) { + const neighbors = this.getNeighbors(row, col); + for (const n of neighbors) { + this.floodFill(grid, n.r, n.c); } - this.audio.playSnakeEat(); - this.spawnFood(); - } else { - this.snake.pop(); } } - private triggerGameOver(): void { + private toggleFlag(row: number, col: number): 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.audio.playGameOver(); - this.render(); // Renderizar el estado de choque final - } + this.face.set('dead'); + this.audio.playMineExplosion(); - private spawnFood(): void { - let valid = false; - 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); + // 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.food = { x: newX, y: newY }; + this.grid.set(currentGrid); } - private render(): void { - if (!this.ctx) return; - const ctx = this.ctx; - const width = GRID_SIZE * CELL_SIZE; // 300 - const height = GRID_SIZE * CELL_SIZE; // 300 + private checkWinCondition(grid: MinesweeperCell[][]): void { + let revealedSafeCount = 0; + const totalSafeCells = ROWS * COLS - TOTAL_MINES; - // 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); + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (!grid[r][c].isMine && grid[r][c].isRevealed) { + revealedSafeCount++; } } } - // Borde exterior - ctx.strokeStyle = '#34495e'; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, width - 2, height - 2); + if (revealedSafeCount === totalSafeCells) { + this.stopTimer(); + this.isWon.set(true); + this.face.set('won'); + this.showWinDialog.set(true); + this.audio.playVictory(); - // 1. DIBUJAR FRUTA (Manzana roja con tallo y hoja) - const fx = this.food.x * CELL_SIZE + CELL_SIZE / 2; - const fy = this.food.y * CELL_SIZE + CELL_SIZE / 2; + // Auto-marcar todas las minas restantes con banderas + const finalGrid = grid.map(r => r.map(c => ({ + ...c, + isFlagged: c.isMine ? true : c.isFlagged + }))); + this.grid.set(finalGrid); - ctx.save(); - // Brillo de la manzana - ctx.fillStyle = '#e74c3c'; - 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); + const time = this.elapsedSeconds(); + const currentBest = this.bestTime(); + if (currentBest === null || time < currentBest) { + this.bestTime.set(time); } - ctx.restore(); - }); + } + } + + closeWinDialog(): void { + this.audio.playClick(); + this.showWinDialog.set(false); + } + + submitScoreAndClose(): void { + this.submitScore(); + this.showWinDialog.set(false); } submitScore(): void { - const finalScore = this.score(); + const finalTime = this.elapsedSeconds(); const tag = (this.playerTag() || 'XP').trim().toUpperCase().slice(0, 4); - if (finalScore <= 0 || this.isScoreSaved()) return; + if (finalTime <= 0 || this.isScoreSaved()) return; this.scorePort.saveScore({ playerTag: tag, - gameCode: 'ZEN_SNAKE', - score: finalScore + gameCode: 'MINESWEEPER', + score: finalTime }).subscribe({ next: () => { this.isScoreSaved.set(true); this.audio.playHighScore(); - this.loadHighScore(); + this.loadBestTime(); } }); } - private loadHighScore(): void { - this.scorePort.getTopScores('ZEN_SNAKE', 1).subscribe({ + private loadBestTime(): void { + this.scorePort.getTopScores('MINESWEEPER', 1).subscribe({ next: (scores) => { - if (scores.length > 0 && scores[0].score > this.highScore()) { - this.highScore.set(scores[0].score); + if (scores.length > 0) { + this.bestTime.set(scores[0].score); } } }); } ngOnDestroy(): void { - this.isPlaying.set(false); - if (this.animationFrameId !== null) { - cancelAnimationFrame(this.animationFrameId); - } + this.stopTimer(); } } diff --git a/src/frontend/src/app/presentation/features/retro-os/components/retro-scores/retro-scores.component.ts b/src/frontend/src/app/presentation/features/retro-os/components/retro-scores/retro-scores.component.ts index 735dc6a..5ebd071 100644 --- a/src/frontend/src/app/presentation/features/retro-os/components/retro-scores/retro-scores.component.ts +++ b/src/frontend/src/app/presentation/features/retro-os/components/retro-scores/retro-scores.component.ts @@ -25,7 +25,7 @@ export class RetroScoresComponent implements OnInit { loadScores(): void { this.isLoading.set(true); - this.scorePort.getTopScores('ZEN_SNAKE', 10).subscribe({ + this.scorePort.getTopScores('MINESWEEPER', 10).subscribe({ next: (data) => { this.scores.set(data); this.isLoading.set(false); diff --git a/src/frontend/src/app/presentation/features/retro-os/components/retro-start-menu/retro-start-menu.component.html b/src/frontend/src/app/presentation/features/retro-os/components/retro-start-menu/retro-start-menu.component.html index 5030aa9..7622980 100644 --- a/src/frontend/src/app/presentation/features/retro-os/components/retro-start-menu/retro-start-menu.component.html +++ b/src/frontend/src/app/presentation/features/retro-os/components/retro-start-menu/retro-start-menu.component.html @@ -29,7 +29,7 @@
- 🎮 + 💣 {{ i18n.t().retroMenuGames }}
diff --git a/src/frontend/src/app/presentation/features/retro-os/retro-os.component.css b/src/frontend/src/app/presentation/features/retro-os/retro-os.component.css index 7cfb441..935f776 100644 --- a/src/frontend/src/app/presentation/features/retro-os/retro-os.component.css +++ b/src/frontend/src/app/presentation/features/retro-os/retro-os.component.css @@ -174,3 +174,33 @@ .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; } .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; + } +} diff --git a/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.css b/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.css index d83d132..d159292 100644 --- a/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.css +++ b/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.css @@ -35,7 +35,6 @@ line-height: 1.65; } -/* Eyebrow */ .editorial-eyebrow { display: block; margin: 0 0 var(--space-1) 0; @@ -55,7 +54,6 @@ line-height: 1.35; } -/* Título Principal H2 */ .editorial-title { margin: 0 0 var(--space-4) 0; font-family: var(--font-sans); @@ -141,32 +139,6 @@ color: var(--zen-text-muted); } -.hacker-screen { - flex: 0 0 clamp(64px, 10vh, 104px); - width: 100%; - overflow: hidden; - position: relative; - background: none; - background-color: transparent; - font-family: var(--font-mono); - isolation: isolate; - border: 0; - box-shadow: none; -} - -.hacker-rain { - position: absolute; - inset: 0; - z-index: 0; - display: block; - background: transparent; - height: 100%; - width: 100%; - overflow: hidden; - pointer-events: none; - font-family: var(--font-mono); -} - @media (max-width: 960px) { .ascii-portrait { width: 56%; diff --git a/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.html b/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.html index c8667db..4d6308a 100644 --- a/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.html +++ b/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.html @@ -1,32 +1,24 @@
- @if (showProfile()) { -
-
- {{ i18n.t().tpEyebrow }} -

{{ i18n.t().tpAboutGreeting }}

+
+
+ {{ i18n.t().tpEyebrow }} +

{{ i18n.t().tpAboutGreeting }}

-
- @if (asciiArt()) { -
{{ asciiArt() }}
- } @else if (asciiLoadError()) { -
No se pudo cargar el retrato.
- } @else { -
Cargando retrato...
- } -
- -

{{ i18n.t().tpAboutName }}

- @for (paragraph of aboutParagraphs(); track $index) { -

{{ paragraph }}

+
+ @if (asciiArt()) { +
{{ asciiArt() }}
+ } @else if (asciiLoadError()) { +
No se pudo cargar el retrato.
+ } @else { +
Cargando retrato...
} - About me -
-
- } + - @if (showMatrix()) { -
- -
- } +

{{ i18n.t().tpAboutName }}

+ @for (paragraph of aboutParagraphs(); track $index) { +

{{ paragraph }}

+ } + About me +
+
diff --git a/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.ts b/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.ts index cdd41f5..a5b868f 100644 --- a/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.ts +++ b/src/frontend/src/app/presentation/features/technical-profile/technical-profile.component.ts @@ -1,32 +1,13 @@ import { - AfterViewInit, ChangeDetectionStrategy, Component, computed, - ElementRef, inject, - input, - OnDestroy, OnInit, - signal, - ViewChild + signal } from '@angular/core'; import { I18nService } from '../../../application/services/i18n.service'; -interface DigitalRainColumn { - x: number; - y: number; - speed: number; - length: number; - opacity: number; - characters: string[]; -} - -const BINARY_CHARACTERS = '01'; -const HEX_CHARACTERS = '0123456789ABCDEF'; -const CODE_CHARACTERS = '{}[]()<>&|/\\:;=+-_*#$%@?!~^'; -const TERMINAL_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - @Component({ selector: 'app-technical-profile', standalone: true, @@ -34,32 +15,15 @@ const TERMINAL_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy styleUrl: './technical-profile.component.css', changeDetection: ChangeDetectionStrategy.OnPush }) -export class TechnicalProfileComponent implements AfterViewInit, OnDestroy, OnInit { - @ViewChild('rainCanvas') private readonly rainCanvas?: ElementRef; - +export class TechnicalProfileComponent implements OnInit { protected readonly i18n = inject(I18nService); - readonly showProfile = input(true); - readonly showMatrix = input(true); 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) ); - 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 { - if (!this.showProfile()) { - return; - } - this.loadTextAsset('assets/portfolio.txt') .then((portrait) => this.asciiArt.set(portrait)) .catch(() => this.asciiLoadError.set(true)); @@ -70,199 +34,7 @@ export class TechnicalProfileComponent implements AfterViewInit, OnDestroy, OnIn if (!response.ok) { throw new Error(`ASCII asset request failed: ${response.status}`); } - return response.text(); }); } - - ngAfterViewInit(): void { - if (!this.showMatrix() || !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 bounds = canvas.getBoundingClientRect(); - const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = Math.max(1, Math.floor(bounds.width * pixelRatio)); - canvas.height = Math.max(1, Math.floor(bounds.height * pixelRatio)); - this.context?.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); - this.createColumns(bounds.width, bounds.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 = this.randomInteger(14, 38); - this.columns.push({ - x: (index / columnCount) * width + Math.random() * fontSize * 0.6, - y: Math.random() * height * 1.4 - height, - speed: this.randomInteger(150, 280), - length, - opacity: 0.16 + Math.random() * 0.68, - characters: Array.from({ length }, () => this.randomCharacter()) - }); - } - } - - private update(deltaSeconds: number): void { - if (!this.rainCanvas) { - return; - } - - const height = this.rainCanvas.nativeElement.clientHeight; - const fontSize = Math.max(14, Math.min(20, this.rainCanvas.nativeElement.clientWidth / 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 = this.randomInteger(150, 280); - column.length = this.randomInteger(14, 38); - column.opacity = 0.16 + Math.random() * 0.68; - column.characters = Array.from({ length: column.length }, () => this.randomCharacter()); - } - } - } - - private draw(): void { - if (!this.rainCanvas) { - return; - } - - const canvas = this.rainCanvas.nativeElement; - const context = this.context; - if (!context) { - return; - } - - const width = canvas.clientWidth; - const height = canvas.clientHeight; - const fontSize = Math.max(14, Math.min(20, width / 58)); - context.clearRect(0, 0, width, height); - context.font = `${fontSize}px ${getComputedStyle(canvas).fontFamily}`; - context.textAlign = 'center'; - context.textBaseline = 'top'; - context.shadowBlur = 0; - - for (const column of this.columns) { - for (let index = 0; index < column.characters.length; index += 1) { - const characterY = column.y + index * fontSize; - if (characterY < -fontSize || characterY > height) { - continue; - } - - const isHead = index === 0; - const isHighlight = index === Math.floor(column.length * 0.35) && column.opacity > 0.5; - const trailAlpha = column.opacity * (1 - index / (column.length * 1.15)); - const edgeFade = Math.min(1, Math.max(0, (height - characterY) / (fontSize * 5))); - const alpha = Math.max(0, trailAlpha * edgeFade); - if (alpha === 0) { - continue; - } - context.fillStyle = isHead - ? `rgba(220, 255, 226, ${Math.min(1, alpha + 0.3)})` - : isHighlight - ? `rgba(80, 255, 126, ${alpha})` - : `rgba(18, 174, 78, ${alpha})`; - if (isHead || isHighlight) { - context.shadowColor = 'rgba(37, 255, 104, 0.8)'; - context.shadowBlur = isHead ? 9 : 5; - } - context.fillText(column.characters[index], column.x, characterY); - context.shadowBlur = 0; - } - } - } - - private randomCharacter(): string { - const characterSet = Math.random() < 0.48 - ? BINARY_CHARACTERS - : Math.random() < 0.68 - ? HEX_CHARACTERS - : Math.random() < 0.72 - ? CODE_CHARACTERS - : TERMINAL_CHARACTERS; - - return characterSet[Math.floor(Math.random() * characterSet.length)]; - } - - private randomInteger(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; - } } diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index 9ccee5a..b256cd4 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -42,10 +42,10 @@ :root, [data-theme='zen-dark'] { --zen-bg-abyss: #07080b; - --zen-bg-base: #0c0e14; - --zen-bg-surface: #131720; - --zen-bg-elevated: #1a202c; - --zen-bg-overlay: rgba(12, 14, 20, 0.85); + --zen-bg-base: #121517; + --zen-bg-surface: #181d24; + --zen-bg-elevated: #1f2530; + --zen-bg-overlay: rgba(18, 21, 23, 0.85); --zen-border-subtle: #1e2533; --zen-border-focus: #3d4a63;