feat: scaffold front-end architecture and import thinkpad animation asset sequence
|
|
@ -0,0 +1,639 @@
|
||||||
|
---
|
||||||
|
name: portfolio-backend
|
||||||
|
description: Parámetros, reglas y estándares de arquitectura backend en C# / .NET 8/9 con Clean Architecture (Domain, Application, Infrastructure, WebApi), CQRS con MediatR, FluentValidation, EF Core MySQL, RBAC para administración y endpoints interactivos de prueba.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Estándar de Arquitectura Backend — Clean Architecture en .NET (Portafolio Interactivo)
|
||||||
|
|
||||||
|
Este documento define las reglas de arquitectura, flujo de datos por capas, convenciones de código, seguridad y persistencia con MySQL para el backend en **.NET 8/9** del portafolio interactivo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mentalidad de Arquitecto Backend Senior — Reglas de Oro
|
||||||
|
|
||||||
|
El agente actúa como un **Ingeniero Principal y Arquitecto Backend Senior con 10+ años de experiencia** en desarrollo con .NET y arquitecturas limpias de alto rendimiento.
|
||||||
|
|
||||||
|
1. **Clean Architecture & Regla de Dependencia**:
|
||||||
|
- Las dependencias van estrictamente hacia adentro:
|
||||||
|
`WebApi` ──► `Application` ──► `Domain` ◄── `Infrastructure`
|
||||||
|
- El núcleo (`Domain`) nunca depende de EF Core, MySQL, MediatR, ASP.NET Core ni librerías externas.
|
||||||
|
- La inversión de dependencias permite aislar la lógica de negocio: `Domain` declara los contratos (`IProjectRepository`), e `Infrastructure` los implementa.
|
||||||
|
2. **Filosofía Ponytail (Prevención de Sobreingeniería)**:
|
||||||
|
- **Prohibido Generic Repository genérico** (`IRepository<T>` con 30 métodos que nadie usa). Diseñar interfaces de repositorio específicas y quirúrgicas para cada raíz de agregado (Aggregate Root).
|
||||||
|
- **Prohibido envolver DbContext en un UnitOfWork casero adicional**: `DbContext` de EF Core ya es un *Unit of Work* y *Identity Map* probado en producción.
|
||||||
|
- **Prohibido AutoMapper**: En su lugar, proyectar directamente con `.Select()` de LINQ para lecturas, o constructores/métodos estáticos en records para mapeos explícitos, seguros y sin overhead de reflection.
|
||||||
|
- **Records posicionales**: Usar `public sealed record ...` para DTOs, Commands y Queries. Cero clases verbosas con getters/setters tradicionales.
|
||||||
|
- **Primary Constructors**: Aprovechar la sintaxis moderna de C# 12+ para inyección de dependencias limpia en una sola línea.
|
||||||
|
3. **Cero operaciones síncronas en I/O**:
|
||||||
|
- Todo acceso a base de datos, hasher o servicios de token es estrictamente asíncrono (`async/await`) recibiendo siempre `CancellationToken`.
|
||||||
|
4. **Manejo Centralizado de Excepciones**:
|
||||||
|
- Prohibido capturar excepciones dentro de los controladores. Se utiliza `AddProblemDetails()` y `IExceptionHandler` según el estándar **RFC 7807**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Límites de Líneas por Archivo y Modularidad Backend
|
||||||
|
|
||||||
|
Para mantener un balance óptimo entre cohesión y separación de responsabilidades (evitando tanto las *God Classes* monolíticas como la sobre-fragmentación artificial en decenas de micro-archivos), se establecen los siguientes umbrales tolerables:
|
||||||
|
|
||||||
|
| Tipo de Archivo | Capa / Ubicación | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Controlador REST** | `WebApi/Controllers/` | **50 – 120** | **200** | Prohibido incluir lógica de negocio. Solo validar HTTP, delegar a MediatR (`ISender`) y retornar `ActionResult<T>`. Permite agrupar las rutas CRUD completas de un recurso. |
|
||||||
|
| **Command / Query Handler** | `Application/Features/.../` | **40 – 90** | **160** | Principio *One Handler per File*. Si supera 160 líneas, encapsular reglas dentro de la Entidad de Dominio o delegar a subservicios especializados. |
|
||||||
|
| **Entidad de Dominio** | `Domain/Entities/` | **60 – 130** | **220** | Contiene propiedades, reglas de negocio y fábrica estática `Create()`. Extraer sub-conceptos a Value Objects si crece. |
|
||||||
|
| **DTO / Command / Query** | `Application/.../DTOs/` | **10 – 40** | **80** | Usar `public sealed record` posicionales. Si se agrupan varios DTOs relacionados en un archivo (ej: `ProjectDtos.cs`), el archivo puede alcanzar hasta **120 líneas**. |
|
||||||
|
| **Validador FluentValidation**| `Application/.../Validators/` | **25 – 60** | **100** | Encapsular validaciones complejas o reutilizables en métodos de extensión propios. |
|
||||||
|
| **Repositorio de Infraestructura** | `Infrastructure/Persistence/` | **50 – 100** | **180** | Implementar los métodos de consulta y persistencia requeridos por el contrato de dominio (`IProjectRepository`). |
|
||||||
|
| **Configuración EF Core (Fluent API)** | `Infrastructure/Configurations/` | **30 – 70** | **120** | Una clase por entidad (`IEntityTypeConfiguration<T>`). |
|
||||||
|
| **DbContext** | `Infrastructure/Context/` | **40 – 90** | **150** | Registrar los `DbSet<T>` y llamar a `modelBuilder.ApplyConfigurationsFromAssembly(...)`. |
|
||||||
|
|
||||||
|
### Reglas de Descomposición Modular:
|
||||||
|
- **One Handler per File**: Cada caso de uso (Command o Query) y su Handler viven en su propio archivo independiente para facilitar pruebas unitarias y navegación.
|
||||||
|
- **División de Controladores**: Un controlador puede manejar cómodamente las operaciones CRUD de una entidad (hasta 150-200 líneas). Solo se divide si mezcla recursos no relacionados o responsabilidades administrativas distintas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mapa de Capas y Responsabilidades
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/
|
||||||
|
├── Portfolio.Domain/ <- Núcleo del Negocio. Cero dependencias externas.
|
||||||
|
│ ├── Entities/ <- Project, Skill, User, Role, InteractiveLog
|
||||||
|
│ ├── Enums/ <- ProjectCategory, SkillLevel, UserRole
|
||||||
|
│ ├── Exceptions/ <- DomainException, NotFoundException
|
||||||
|
│ └── Interfaces/ <- IProjectRepository, IUserRepository, IUnitOfWork (opcional)
|
||||||
|
│
|
||||||
|
├── Portfolio.Application/ <- Casos de Uso (CQRS). Solo depende de Domain.
|
||||||
|
│ ├── Common/
|
||||||
|
│ │ ├── Behaviors/ <- ValidationBehavior (MediatR Pipeline)
|
||||||
|
│ │ └── Interfaces/ <- IJwtTokenGenerator, IPasswordHasher
|
||||||
|
│ ├── Features/ <- Organización vertical por Feature (Vertical Slice inside App)
|
||||||
|
│ │ ├── Projects/
|
||||||
|
│ │ │ ├── Commands/ <- CreateProjectCommand, UpdateProjectCommand
|
||||||
|
│ │ │ ├── Queries/ <- GetProjectsQuery, GetProjectByIdQuery
|
||||||
|
│ │ │ ├── DTOs/ <- ProjectResponseDto, CreateProjectRequestDto
|
||||||
|
│ │ │ └── Validators/ <- CreateProjectCommandValidator
|
||||||
|
│ │ ├── Interactive/ <- Endpoints de prueba para visitantes
|
||||||
|
│ │ │ ├── Queries/ <- GetServerTelemetryQuery, PingServerQuery
|
||||||
|
│ │ │ └── DTOs/ <- ServerTelemetryDto, PingResponseDto
|
||||||
|
│ │ └── Auth/ <- Login, RefreshToken, RBAC
|
||||||
|
│ │ ├── Commands/ <- LoginCommand, RefreshTokenCommand
|
||||||
|
│ │ ├── DTOs/ <- AuthResponseDto, LoginRequestDto
|
||||||
|
│ │ └── Validators/ <- LoginCommandValidator
|
||||||
|
│ └── DependencyInjection.cs
|
||||||
|
│
|
||||||
|
├── Portfolio.Infrastructure/ <- Implementación Técnica. Depende de Application y Domain.
|
||||||
|
│ ├── Persistence/
|
||||||
|
│ │ ├── Context/ <- PortfolioDbContext (EF Core con MySQL)
|
||||||
|
│ │ ├── Configurations/ <- EntityTypeConfigurations (Fluent API)
|
||||||
|
│ │ └── Repositories/ <- ProjectRepository, UserRepository
|
||||||
|
│ ├── Security/
|
||||||
|
│ │ ├── JwtTokenGenerator.cs <- Generación y validación de tokens JWT
|
||||||
|
│ │ └── PasswordHasher.cs <- Hasheo de contraseñas (BCrypt / PBKDF2)
|
||||||
|
│ └── DependencyInjection.cs
|
||||||
|
│
|
||||||
|
└── Portfolio.WebApi/ <- Punto de Entrada HTTP. Depende de Application e Infrastructure.
|
||||||
|
├── Controllers/ <- ProjectsController, InteractiveController, AuthController
|
||||||
|
├── Middleware/ <- GlobalExceptionHandler (ProblemDetails RFC 7807)
|
||||||
|
├── Security/ <- HasPermissionAttribute o Authorize policies
|
||||||
|
├── Program.cs <- Pipeline HTTP, DI y configuración
|
||||||
|
└── appsettings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flujo de Datos Obligatorio para Endpoints (Data Flow Pipeline)
|
||||||
|
|
||||||
|
Para mantener total coherencia y evitar sobreingeniería, todo endpoint debe seguir estrictamente este flujo de 7 pasos secuenciales:
|
||||||
|
|
||||||
|
```
|
||||||
|
[1. Request HTTP / DTO]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[2. Command / Query (MediatR IRequest<T>)]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[3. FluentValidation (Pipeline Behavior)]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[4. Handler (IRequestHandler<T, TResponse>)]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[5. Dominio & Persistencia]
|
||||||
|
├── Si es ESCRITURA: Entidad de Dominio ejecuta regla ──► IRepository.AddAsync() ──► SaveChangesAsync()
|
||||||
|
└── Si es LECTURA: DbContext.Set<T>().AsNoTracking().Select(...) ──► Directo a DTO (Sin overhead)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[6. Response DTO (Record Inmutable)]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[7. Controller Action] (Retorna ActionResult<TResponse> con 200, 201 o 204)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementación Paso a Paso con Código Real
|
||||||
|
|
||||||
|
### Paso 1: Definir los DTOs (Records Inmutables)
|
||||||
|
Los DTOs definen los contratos externos sin exponer la estructura de base de datos.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Application/Features/Projects/DTOs/ProjectDtos.cs
|
||||||
|
namespace Portfolio.Application.Features.Projects.DTOs;
|
||||||
|
|
||||||
|
public sealed record CreateProjectRequestDto(
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string RepositoryUrl,
|
||||||
|
string? LiveDemoUrl,
|
||||||
|
string[] Technologies,
|
||||||
|
bool IsFeatured
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ProjectResponseDto(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string RepositoryUrl,
|
||||||
|
string? LiveDemoUrl,
|
||||||
|
IReadOnlyList<string> Technologies,
|
||||||
|
bool IsFeatured,
|
||||||
|
DateTime CreatedAtUtc
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 2: Command o Query (MediatR `IRequest<T>`)
|
||||||
|
Representa la intención del usuario. El Command muta estado; la Query solo consulta.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Application/Features/Projects/Commands/CreateProjectCommand.cs
|
||||||
|
using MediatR;
|
||||||
|
using Portfolio.Application.Features.Projects.DTOs;
|
||||||
|
|
||||||
|
namespace Portfolio.Application.Features.Projects.Commands;
|
||||||
|
|
||||||
|
public sealed record CreateProjectCommand(
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string RepositoryUrl,
|
||||||
|
string? LiveDemoUrl,
|
||||||
|
string[] Technologies,
|
||||||
|
bool IsFeatured
|
||||||
|
) : IRequest<ProjectResponseDto>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 3: Validación con FluentValidation
|
||||||
|
Valida la integridad de los datos antes de que el Handler sea ejecutado.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Application/Features/Projects/Validators/CreateProjectCommandValidator.cs
|
||||||
|
using FluentValidation;
|
||||||
|
using Portfolio.Application.Features.Projects.Commands;
|
||||||
|
|
||||||
|
namespace Portfolio.Application.Features.Projects.Validators;
|
||||||
|
|
||||||
|
public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
|
||||||
|
{
|
||||||
|
public CreateProjectCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("El título del proyecto es obligatorio.")
|
||||||
|
.MaximumLength(150).WithMessage("El título no puede exceder 150 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Description)
|
||||||
|
.NotEmpty().WithMessage("La descripción es obligatoria.")
|
||||||
|
.MaximumLength(1000).WithMessage("La descripción no puede exceder 1000 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.RepositoryUrl)
|
||||||
|
.NotEmpty().WithMessage("La URL del repositorio es obligatoria.")
|
||||||
|
.Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
|
||||||
|
.WithMessage("La URL del repositorio no es válida.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Technologies)
|
||||||
|
.NotEmpty().WithMessage("Debe incluir al menos una tecnología.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pipeline de Validación Automático (ValidationBehavior)
|
||||||
|
MediatR ejecuta este pipeline antes del Handler; si la validación falla, lanza `ValidationException` que el `GlobalExceptionHandler` captura retornando HTTP 422:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Application/Common/Behaviors/ValidationBehavior.cs
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Portfolio.Application.Common.Behaviors;
|
||||||
|
|
||||||
|
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
: IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : IRequest<TResponse>
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!validators.Any()) return await next();
|
||||||
|
|
||||||
|
var context = new ValidationContext<TRequest>(request);
|
||||||
|
var validationResults = await Task.WhenAll(
|
||||||
|
validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||||
|
|
||||||
|
var failures = validationResults
|
||||||
|
.SelectMany(r => r.Errors)
|
||||||
|
.Where(f => f != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (failures.Count != 0)
|
||||||
|
throw new ValidationException(failures);
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 4: Handler (`IRequestHandler<T, TResponse>`)
|
||||||
|
Orquesta la lógica de negocio delegando la persistencia a través de la interfaz del repositorio.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Application/Features/Projects/Commands/CreateProjectCommandHandler.cs
|
||||||
|
using MediatR;
|
||||||
|
using Portfolio.Application.Features.Projects.DTOs;
|
||||||
|
using Portfolio.Domain.Entities;
|
||||||
|
using Portfolio.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace Portfolio.Application.Features.Projects.Commands;
|
||||||
|
|
||||||
|
public sealed class CreateProjectCommandHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<CreateProjectCommand, ProjectResponseDto>
|
||||||
|
{
|
||||||
|
public async Task<ProjectResponseDto> Handle(
|
||||||
|
CreateProjectCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Creación y encapsulación en entidad de dominio
|
||||||
|
var project = Project.Create(
|
||||||
|
request.Title,
|
||||||
|
request.Description,
|
||||||
|
request.RepositoryUrl,
|
||||||
|
request.LiveDemoUrl,
|
||||||
|
request.Technologies,
|
||||||
|
request.IsFeatured
|
||||||
|
);
|
||||||
|
|
||||||
|
await projectRepository.AddAsync(project, cancellationToken);
|
||||||
|
await projectRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new ProjectResponseDto(
|
||||||
|
project.Id,
|
||||||
|
project.Title,
|
||||||
|
project.Description,
|
||||||
|
project.RepositoryUrl,
|
||||||
|
project.LiveDemoUrl,
|
||||||
|
project.Technologies,
|
||||||
|
project.IsFeatured,
|
||||||
|
project.CreatedAtUtc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 5: Dominio e Inversión de Persistencia
|
||||||
|
|
||||||
|
#### A. Entidad de Dominio Pura (Sin EF Core)
|
||||||
|
```csharp
|
||||||
|
// Domain/Entities/Project.cs
|
||||||
|
namespace Portfolio.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class Project
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public string Title { get; private set; } = string.Empty;
|
||||||
|
public string Description { get; private set; } = string.Empty;
|
||||||
|
public string RepositoryUrl { get; private set; } = string.Empty;
|
||||||
|
public string? LiveDemoUrl { get; private set; }
|
||||||
|
public List<string> Technologies { get; private set; } = [];
|
||||||
|
public bool IsFeatured { get; private set; }
|
||||||
|
public DateTime CreatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private Project() { } // Constructor privado para EF Core
|
||||||
|
|
||||||
|
public static Project Create(
|
||||||
|
string title,
|
||||||
|
string description,
|
||||||
|
string repoUrl,
|
||||||
|
string? demoUrl,
|
||||||
|
string[] technologies,
|
||||||
|
bool isFeatured)
|
||||||
|
{
|
||||||
|
return new Project
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Title = title.Trim(),
|
||||||
|
Description = description.Trim(),
|
||||||
|
RepositoryUrl = repoUrl.Trim(),
|
||||||
|
LiveDemoUrl = demoUrl?.Trim(),
|
||||||
|
Technologies = [..technologies],
|
||||||
|
IsFeatured = isFeatured,
|
||||||
|
CreatedAtUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Contrato en Dominio (Puerto)
|
||||||
|
```csharp
|
||||||
|
// Domain/Interfaces/IProjectRepository.cs
|
||||||
|
using Portfolio.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Portfolio.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IProjectRepository
|
||||||
|
{
|
||||||
|
Task<Project?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken cancellationToken);
|
||||||
|
Task AddAsync(Project project, CancellationToken cancellationToken);
|
||||||
|
void Remove(Project project);
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Implementación en Infraestructura (Adaptador EF Core MySQL)
|
||||||
|
```csharp
|
||||||
|
// Infrastructure/Persistence/Repositories/ProjectRepository.cs
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Portfolio.Domain.Entities;
|
||||||
|
using Portfolio.Domain.Interfaces;
|
||||||
|
using Portfolio.Infrastructure.Persistence.Context;
|
||||||
|
|
||||||
|
namespace Portfolio.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
|
public sealed class ProjectRepository(PortfolioDbContext context) : IProjectRepository
|
||||||
|
{
|
||||||
|
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
=> await context.Projects.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken cancellationToken)
|
||||||
|
=> await context.Projects.AsNoTracking().ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
public async Task AddAsync(Project project, CancellationToken cancellationToken)
|
||||||
|
=> await context.Projects.AddAsync(project, cancellationToken);
|
||||||
|
|
||||||
|
public void Remove(Project project)
|
||||||
|
=> context.Projects.Remove(project);
|
||||||
|
|
||||||
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken)
|
||||||
|
=> await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### D. Contexto EF Core MySQL y Migraciones
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Infrastructure/Persistence/Context/PortfolioDbContext.cs
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Portfolio.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Portfolio.Infrastructure.Persistence.Context;
|
||||||
|
|
||||||
|
public sealed class PortfolioDbContext(DbContextOptions<PortfolioDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
|
public DbSet<Skill> Skills => Set<Skill>();
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(PortfolioDbContext).Assembly);
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registro de Inyección en `Infrastructure/DependencyInjection.cs`:
|
||||||
|
```csharp
|
||||||
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||||
|
services.AddDbContext<PortfolioDbContext>(options =>
|
||||||
|
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
|
||||||
|
|
||||||
|
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Comandos de Migración MySQL:
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add InitialCreate --project src/Portfolio.Infrastructure --startup-project src/Portfolio.WebApi
|
||||||
|
dotnet ef database update --project src/Portfolio.Infrastructure --startup-project src/Portfolio.WebApi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 6 & 7: Controlador REST en WebApi
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// WebApi/Controllers/ProjectsController.cs
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Portfolio.Application.Features.Projects.Commands;
|
||||||
|
using Portfolio.Application.Features.Projects.DTOs;
|
||||||
|
using Portfolio.Application.Features.Projects.Queries;
|
||||||
|
|
||||||
|
namespace Portfolio.WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/[controller]")]
|
||||||
|
public sealed class ProjectsController(ISender sender) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(IEnumerable<ProjectResponseDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<ProjectResponseDto>>> GetAll(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetProjectsQuery(), cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")] // Protegido por RBAC para que Bryan gestione sus proyectos
|
||||||
|
[ProducesResponseType(typeof(ProjectResponseDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<ProjectResponseDto>> Create(
|
||||||
|
[FromBody] CreateProjectRequestDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var command = new CreateProjectCommand(
|
||||||
|
request.Title,
|
||||||
|
request.Description,
|
||||||
|
request.RepositoryUrl,
|
||||||
|
request.LiveDemoUrl,
|
||||||
|
request.Technologies,
|
||||||
|
request.IsFeatured
|
||||||
|
);
|
||||||
|
|
||||||
|
var created = await sender.Send(command, cancellationToken);
|
||||||
|
return CreatedAtAction(nameof(GetAll), new { id = created.Id }, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Endpoints Interactivos Públicos (Live Playground)
|
||||||
|
|
||||||
|
Para que los visitantes y reclutadores interactúen con el backend sin autenticación pero con rate-limiting nativo para evitar abusos o denegación de servicio:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// WebApi/Controllers/InteractiveController.cs
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Portfolio.Application.Features.Interactive.DTOs;
|
||||||
|
using Portfolio.Application.Features.Interactive.Queries;
|
||||||
|
|
||||||
|
namespace Portfolio.WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/[controller]")]
|
||||||
|
[EnableRateLimiting("interactive-policy")] // Límite nativo de .NET 8/9 (ej. 30 req/min por IP)
|
||||||
|
public sealed class InteractiveController(ISender sender) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("ping")]
|
||||||
|
[ProducesResponseType(typeof(PingResponseDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PingResponseDto>> Ping(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await sender.Send(new PingServerQuery(), cancellationToken);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("telemetry")]
|
||||||
|
[ProducesResponseType(typeof(ServerTelemetryDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ServerTelemetryDto>> Telemetry(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await sender.Send(new GetServerTelemetryQuery(), cancellationToken);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuración en `WebApi/Program.cs`:
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddFixedWindowLimiter("interactive-policy", opt =>
|
||||||
|
{
|
||||||
|
opt.PermitLimit = 30; // Máximo 30 peticiones
|
||||||
|
opt.Window = TimeSpan.FromMinutes(1); // por cada minuto
|
||||||
|
opt.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// En el pipeline:
|
||||||
|
app.UseRateLimiter();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Seguridad y RBAC (Administración para Bryan)
|
||||||
|
|
||||||
|
1. **Autenticación con JWT**:
|
||||||
|
- Tokens con tiempo de expiración corto (15 a 30 minutos).
|
||||||
|
- `ClockSkew = TimeSpan.Zero` obligatorio para no permitir ventanas de tokens vencidos.
|
||||||
|
2. **Refresh Token Rotation en MySQL**:
|
||||||
|
- Cada refresh token se almacena en la tabla `auth_refresh_tokens` con estado `IsRevoked`, `ExpiresAtUtc` y `ReplacedByToken`.
|
||||||
|
- Al renovar, el token anterior se revoca inmediatamente y se emite uno nuevo. Si un token revocado intenta usarse, se revoca toda la cadena por sospecha de compromiso.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Domain/Entities/RefreshToken.cs
|
||||||
|
namespace Portfolio.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class RefreshToken
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
public string TokenHash { get; private set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAtUtc { get; private set; }
|
||||||
|
public bool IsRevoked { get; private set; }
|
||||||
|
public DateTime CreatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
public bool IsActive => !IsRevoked && DateTime.UtcNow < ExpiresAtUtc;
|
||||||
|
public void Revoke() => IsRevoked = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **RBAC Simple y Efectivo**:
|
||||||
|
- Roles en Claim: `Role: Admin`, `Role: Visitor`.
|
||||||
|
- Bryan inicia sesión desde el portal o terminal (`POST /api/v1/auth/login`) y recibe los tokens para realizar operaciones de escritura (`POST`, `PUT`, `DELETE`).
|
||||||
|
- Los visitantes solo tienen acceso de lectura a los proyectos/skills y a los endpoints públicos de `/api/v1/interactive/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Manejo Centralizado de Errores — ProblemDetails RFC 7807
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// WebApi/Middleware/GlobalExceptionHandler.cs
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Portfolio.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace Portfolio.WebApi.Middleware;
|
||||||
|
|
||||||
|
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
|
||||||
|
{
|
||||||
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Excepción capturada: {Message}", exception.Message);
|
||||||
|
|
||||||
|
var (statusCode, title) = exception switch
|
||||||
|
{
|
||||||
|
NotFoundException => (StatusCodes.Status404NotFound, "Recurso no encontrado"),
|
||||||
|
FluentValidation.ValidationException => (StatusCodes.Status422UnprocessableEntity, "Error de validación"),
|
||||||
|
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "No autorizado"),
|
||||||
|
_ => (StatusCodes.Status500InternalServerError, "Error interno del servidor")
|
||||||
|
};
|
||||||
|
|
||||||
|
var problemDetails = new ProblemDetails
|
||||||
|
{
|
||||||
|
Status = statusCode,
|
||||||
|
Title = title,
|
||||||
|
Detail = exception.Message,
|
||||||
|
Instance = httpContext.Request.Path
|
||||||
|
};
|
||||||
|
|
||||||
|
httpContext.Response.StatusCode = statusCode;
|
||||||
|
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Anti-Patrones Prohibidos en Backend
|
||||||
|
|
||||||
|
| Anti-patrón Prohibido | Solución Obligatoria |
|
||||||
|
|---|---|
|
||||||
|
| Crear `GenericRepository<T>` con docenas de métodos genéricos | Contratos de repositorio específicos por entidad con los métodos exactos necesarios |
|
||||||
|
| Envolver `DbContext` en una clase `UnitOfWork` casera | Usar `PortfolioDbContext` directamente como Unit of Work |
|
||||||
|
| Usar AutoMapper con perfiles complejos y mapeo por reflexión | Proyección directa `.Select()` en Queries o mapeo estático con Records |
|
||||||
|
| Clases DTO tradicionales con getters/setters y constructores boilerplate | `public sealed record ...` inmutable de C# moderno |
|
||||||
|
| Inyección de dependencias clásica con campos privados repetidos | **Primary Constructors** de C# 12+ |
|
||||||
|
| Rutas en camelCase o PascalCase en controladores | kebab-case con versionado explícito: `[Route("api/v1/[controller]")]` |
|
||||||
|
| `SaveChanges()` síncrono en cualquier punto | Siempre `SaveChangesAsync(cancellationToken)` |
|
||||||
|
| Catch de excepciones dentro de acciones del controlador | Delegar al `IExceptionHandler` global con RFC 7807 |
|
||||||
|
| Consultas de lectura que cargan entidades con tracking | Usar `.AsNoTracking()` o `.Select()` directo a DTO |
|
||||||
|
|
@ -0,0 +1,665 @@
|
||||||
|
---
|
||||||
|
name: portfolio-frontend
|
||||||
|
description: Parámetros, reglas y estándares de arquitectura frontend con Clean Architecture en Angular (Signals reactivos, Domain Ports & Adapters, Application Stores, Presentation OnPush, Terminal interactiva, Retro OS Canvas y 3D Scroll Hero).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Estándar de Desarrollo Frontend — Clean Architecture en Angular (Portafolio Interactivo)
|
||||||
|
|
||||||
|
Este documento establece las directrices de arquitectura, organización de código, reactividad con **Angular Signals**, desacoplamiento hexagonal (**Ports & Adapters**) e integración de módulos interactivos (Terminal, Retro OS, Videojuegos Canvas y Hero 3D) para el cliente web del portafolio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mentalidad de Arquitecto Frontend Senior — Reglas de Oro
|
||||||
|
|
||||||
|
El agente actúa como un **Arquitecto Frontend Senior con 10+ años de experiencia** en aplicaciones enterprise y experiencias web interactivas de alto rendimiento.
|
||||||
|
|
||||||
|
1. **Clean Architecture / Inversión de Dependencias (DIP)**:
|
||||||
|
- Las capas internas nunca conocen a las externas. El código fuente solo apunta hacia el centro:
|
||||||
|
`Presentation` ──► `Application` ──► `Domain` ◄── `Infrastructure / Data`
|
||||||
|
- El **Dominio** es TypeScript puro, agnóstico del framework (cero imports de `@angular/*`, cero `HttpClient`).
|
||||||
|
- Los cambios de endpoints en el backend solo afectan a `infrastructure/`, jamás a la UI ni al Dominio.
|
||||||
|
2. **Reactividad Primitiva con Signals & OnPush**:
|
||||||
|
- Todo componente en la capa de presentación debe usar `changeDetection: ChangeDetectionStrategy.OnPush`.
|
||||||
|
- El estado se administra exclusivamente con **Angular Signals** (`signal`, `computed`, `effect`).
|
||||||
|
- Prohibido abusar de `BehaviorSubject` para estados locales o UI cuando `signal()` es nativo, más limpio y de menor overhead.
|
||||||
|
- RxJS se reserva exclusivamente para flujos asíncronos y cancelables en adaptadores de infraestructura (`HttpClient`).
|
||||||
|
3. **Smart vs. Dumb Components**:
|
||||||
|
- **Smart Components (Pages/Containers)**: Inyectan Application Stores o Facades, coordinan la vista y pasan señales a los componentes hijos.
|
||||||
|
- **Dumb / Presentational Components (UI)**: Reciben datos estrictamente por `input()` / `input.required()` y notifican eventos mediante `output()`. Cero inyecciones de repositorios o servicios HTTP.
|
||||||
|
4. **Sintaxis Moderna de Control de Flujo**:
|
||||||
|
- Uso obligatorio de la nueva sintaxis `@if`, `@for` (con `track`), `@switch` y `@defer`.
|
||||||
|
- Prohibido el uso de directivas estructurales legadas (`*ngIf`, `*ngFor`).
|
||||||
|
5. **Filosofía Ponytail (Cero Sobreingeniería)**:
|
||||||
|
- Usar las herramientas nativas de Angular y la Web Platform antes de añadir paquetes `npm`.
|
||||||
|
- Mini-juego retro: Usar la **API nativa Canvas 2D** en lugar de motores de juego pesados de 500KB.
|
||||||
|
- Scroll 3D: Implementar con Three.js optimizado o Canvas/CSS 3D ligero, pausando el render loop cuando no esté visible con `IntersectionObserver`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Límites de Líneas por Archivo y Modularidad de Componentes
|
||||||
|
|
||||||
|
Para equilibrar la modularidad sin caer en una fragmentación excesiva (evitando crear cientos de micro-archivos innecesarios), se establecen los siguientes umbrales tolerables:
|
||||||
|
|
||||||
|
| Tipo de Archivo | Extensión / Ubicación | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Componente TypeScript** | `*.component.ts` | **80 – 180** | **280** | Extraer lógica pesada al Store o delegar bloques de vista en subcomponentes Dumb. |
|
||||||
|
| **Plantilla HTML** | `*.component.html` | **50 – 130** | **200** | Descomponer bloques visuales complejos en subcomponentes presentacionales hijos. |
|
||||||
|
| **Hojas de Estilo** | `*.component.css` | **40 – 100** | **160** | Consumir tokens globales de diseño y utilidades para no duplicar reglas CSS. |
|
||||||
|
| **Application Stores** | `*.store.ts` | **80 – 160** | **240** | Dividir por dominio funcional o sub-estado específico si acumula demasiados flujos. |
|
||||||
|
| **Servicios / Casos de Uso** | `*.service.ts` / `*.usecase.ts` | **50 – 120** | **180** | Aplicar Single Responsibility (SRP): mantener el servicio acotado a su función. |
|
||||||
|
| **Modelos y Puertos** | `domain/models/`, `ports/` | **30 – 70** | **120** | Separar modelos o agrupar contratos relacionados de forma coherente. |
|
||||||
|
| **Adaptadores y Repositorios**| `infrastructure/adapters/` | **60 – 120** | **180** | Mantener solo las llamadas HTTP del recurso específico. |
|
||||||
|
| **Mappers y DTOs** | `infrastructure/mappers/` | **30 – 80** | **130** | Dividir funciones de mapeo si el DTO tiene muchas transformaciones complejas. |
|
||||||
|
| **Motor de Juego / Canvas** | `retro-game/` | **100 – 200** | **320** | Separar en módulos si el juego crece: `game-engine.ts`, `renderer.ts`, `input-handler.ts`. |
|
||||||
|
|
||||||
|
### Criterio de Descomposición Inteligente:
|
||||||
|
- **No forzar la micro-fragmentación**: No es necesario crear un subcomponente por cada botón o etiqueta. Se crea un nuevo subcomponente cuando una sección visual o interactiva tiene identidad propia (ej. `terminal-prompt`, `retro-window-header`, `game-scoreboard`).
|
||||||
|
- **Control Flow Anidado**: Si una plantilla HTML supera **3 a 4 niveles de anidamiento** de bloques `@if` o `@for`, es momento de extraer ese bloque interno a un subcomponente Dumb dedicado para mantener la legibilidad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Diagrama de Capas de Arquitectura Limpia
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PRESENTATION LAYER (Angular) │
|
||||||
|
│ - Pages (Hero, Terminal, Retro OS, Projects, Admin) │
|
||||||
|
│ - Dumb Components (WindowFrame, TerminalView, GameCanvas) │
|
||||||
|
│ - UI Directives & Pipes │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ consume
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ APPLICATION LAYER (Stores & Use Cases) │
|
||||||
|
│ - ProjectStore (Signals) - TerminalEngine (Signals) │
|
||||||
|
│ - RetroOsStore (Signals) - AuthStore (RBAC Signals) │
|
||||||
|
│ - InteractiveApiStore │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ orquesta usando puertos
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ DOMAIN LAYER (Core Puro) │
|
||||||
|
│ - Models (Project, Skill, Command, RetroWindow, AuthUser) │
|
||||||
|
│ - Ports (Abstract Classes / Injection Tokens) │
|
||||||
|
│ - Domain Value Objects & Business Rules │
|
||||||
|
└──────────────────────────────▲──────────────────────────────┘
|
||||||
|
│ implementa contratos de
|
||||||
|
┌──────────────────────────────┴──────────────────────────────┐
|
||||||
|
│ INFRASTRUCTURE / DATA LAYER (Adapters) │
|
||||||
|
│ - HttpProjectRepository - HttpInteractiveRepository │
|
||||||
|
│ - HttpAuthRepository - LocalStorageAdapter │
|
||||||
|
│ - DTOs & Mappers puros (DTO <-> Model) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Estructura de Directorios del Proyecto Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/app/
|
||||||
|
├── domain/ <- TypeScript PURO (Sin Angular imports)
|
||||||
|
│ ├── models/ <- Modelos inmutables de negocio
|
||||||
|
│ │ ├── project.model.ts
|
||||||
|
│ │ ├── skill.model.ts
|
||||||
|
│ │ ├── terminal-command.model.ts
|
||||||
|
│ │ ├── retro-os.model.ts
|
||||||
|
│ │ ├── interactive-demo.model.ts
|
||||||
|
│ │ └── auth.model.ts
|
||||||
|
│ └── ports/ <- Contratos abstractos (Interfaces / Abstract Classes)
|
||||||
|
│ ├── project.port.ts
|
||||||
|
│ ├── interactive-demo.port.ts
|
||||||
|
│ └── auth.port.ts
|
||||||
|
│
|
||||||
|
├── infrastructure/ <- Implementaciones técnicas y adaptadores
|
||||||
|
│ ├── dtos/ <- Contratos directos de la API .NET
|
||||||
|
│ │ ├── project.dto.ts
|
||||||
|
│ │ ├── interactive-demo.dto.ts
|
||||||
|
│ │ └── auth.dto.ts
|
||||||
|
│ ├── mappers/ <- Transformadores puros (DTO <-> Model)
|
||||||
|
│ │ ├── project.mapper.ts
|
||||||
|
│ │ ├── interactive-demo.mapper.ts
|
||||||
|
│ │ └── auth.mapper.ts
|
||||||
|
│ └── adapters/ <- Implementación HTTP con HttpClient
|
||||||
|
│ ├── http-project.adapter.ts
|
||||||
|
│ ├── http-interactive-demo.adapter.ts
|
||||||
|
│ └── http-auth.adapter.ts
|
||||||
|
│
|
||||||
|
├── application/ <- Orquestación de casos de uso y estado reactivo
|
||||||
|
│ ├── stores/ <- Signals Stores (State Management)
|
||||||
|
│ │ ├── project.store.ts
|
||||||
|
│ │ ├── terminal.store.ts
|
||||||
|
│ │ ├── retro-os.store.ts
|
||||||
|
│ │ ├── interactive-api.store.ts
|
||||||
|
│ │ └── auth.store.ts
|
||||||
|
│ └── services/ <- Lógica de negocio de aplicación
|
||||||
|
│ ├── terminal-executor.service.ts <- Parser y ejecutor de comandos
|
||||||
|
│ └── retro-audio.service.ts <- Efectos de sonido retro con Web Audio API
|
||||||
|
│
|
||||||
|
├── presentation/ <- UI Angular (Componentes Standalone OnPush)
|
||||||
|
│ ├── features/
|
||||||
|
│ │ ├── hero-3d/ <- Laptop desarmable en scroll 3D
|
||||||
|
│ │ │ ├── hero-3d.component.ts
|
||||||
|
│ │ │ └── hero-3d.component.html
|
||||||
|
│ │ ├── terminal/ <- Consola Linux interactiva
|
||||||
|
│ │ │ ├── terminal.component.ts
|
||||||
|
│ │ │ ├── terminal.component.html
|
||||||
|
│ │ │ └── components/terminal-line.component.ts
|
||||||
|
│ │ ├── retro-os/ <- Sistema operativo vintage & mini-juego
|
||||||
|
│ │ │ ├── retro-os.component.ts
|
||||||
|
│ │ │ ├── retro-os.component.html
|
||||||
|
│ │ │ └── components/
|
||||||
|
│ │ │ ├── retro-window.component.ts
|
||||||
|
│ │ │ └── retro-game-canvas.component.ts
|
||||||
|
│ │ ├── interactive-api/ <- Probador de API en vivo (.NET Playground)
|
||||||
|
│ │ │ ├── interactive-api.component.ts
|
||||||
|
│ │ │ └── interactive-api.component.html
|
||||||
|
│ │ ├── projects/ <- Galería de proyectos con filtros
|
||||||
|
│ │ │ ├── project-list.component.ts
|
||||||
|
│ │ │ └── project-card.component.ts
|
||||||
|
│ │ └── admin/ <- Backoffice protegido por RBAC
|
||||||
|
│ │ ├── login/login.component.ts
|
||||||
|
│ │ └── dashboard/admin-dashboard.component.ts
|
||||||
|
│ └── shared/ <- Componentes UI reutilizables
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── zen-button/zen-button.component.ts
|
||||||
|
│ │ └── zen-badge/zen-badge.component.ts
|
||||||
|
│ └── pipes/
|
||||||
|
│ └── json-pretty.pipe.ts
|
||||||
|
│
|
||||||
|
├── core/ <- Servicios transversales
|
||||||
|
│ ├── guards/
|
||||||
|
│ │ └── auth.guard.ts <- Verificación de token y rol admin
|
||||||
|
│ ├── interceptors/
|
||||||
|
│ │ ├── auth.interceptor.ts <- Inyección de Bearer Token JWT
|
||||||
|
│ │ └── error.interceptor.ts <- Mapeo de ProblemDetails RFC 7807
|
||||||
|
│ └── tokens/
|
||||||
|
│ └── api-config.token.ts
|
||||||
|
│
|
||||||
|
├── app.config.ts <- Inyección de dependencias (providers)
|
||||||
|
└── app.routes.ts <- Rutas principales con Lazy Loading
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementación Práctica por Capas (Ejemplos Estándar)
|
||||||
|
|
||||||
|
### 5.1 Capa de Dominio (`domain/`)
|
||||||
|
|
||||||
|
Modelos inmutables en TypeScript puro. Cero decoradores `@Injectable` o librerías externas.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// domain/models/interactive-demo.model.ts
|
||||||
|
export interface ServerTelemetry {
|
||||||
|
readonly status: string;
|
||||||
|
readonly framework: string;
|
||||||
|
readonly databaseEngine: string;
|
||||||
|
readonly uptimeSeconds: number;
|
||||||
|
readonly serverTimeUtc: string;
|
||||||
|
readonly latencyMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDemoResult {
|
||||||
|
readonly endpoint: string;
|
||||||
|
readonly httpStatus: number;
|
||||||
|
readonly executionTimeMs: number;
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly isSuccess: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// domain/ports/interactive-demo.port.ts
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiDemoResult, ServerTelemetry } from '../models/interactive-demo.model';
|
||||||
|
|
||||||
|
export abstract class InteractiveDemoPort {
|
||||||
|
abstract getTelemetry(): Observable<ServerTelemetry>;
|
||||||
|
abstract pingServer(): Observable<ApiDemoResult>;
|
||||||
|
abstract executeCustomDemo(demoId: string): Observable<ApiDemoResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Capa de Infraestructura (`infrastructure/`)
|
||||||
|
|
||||||
|
Maneja la comunicación HTTP, DTOs y Mappers hacia el backend .NET.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// infrastructure/dtos/interactive-demo.dto.ts
|
||||||
|
export interface ServerTelemetryDto {
|
||||||
|
status: string;
|
||||||
|
framework: string;
|
||||||
|
database_engine: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
server_time_utc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiExecutionResponseDto<T = unknown> {
|
||||||
|
endpoint: string;
|
||||||
|
status_code: number;
|
||||||
|
duration_ms: number;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// infrastructure/mappers/interactive-demo.mapper.ts
|
||||||
|
import { ApiDemoResult, ServerTelemetry } from '../../domain/models/interactive-demo.model';
|
||||||
|
import { ApiExecutionResponseDto, ServerTelemetryDto } from '../dtos/interactive-demo.dto';
|
||||||
|
|
||||||
|
export class InteractiveDemoMapper {
|
||||||
|
static toDomainTelemetry(dto: ServerTelemetryDto, latencyMs: number): ServerTelemetry {
|
||||||
|
return {
|
||||||
|
status: dto.status,
|
||||||
|
framework: dto.framework,
|
||||||
|
databaseEngine: dto.database_engine,
|
||||||
|
uptimeSeconds: dto.uptime_seconds,
|
||||||
|
serverTimeUtc: dto.server_time_utc,
|
||||||
|
latencyMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static toDomainDemoResult(dto: ApiExecutionResponseDto): ApiDemoResult {
|
||||||
|
return {
|
||||||
|
endpoint: dto.endpoint,
|
||||||
|
httpStatus: dto.status_code,
|
||||||
|
executionTimeMs: dto.duration_ms,
|
||||||
|
payload: dto.data,
|
||||||
|
isSuccess: dto.status_code >= 200 && dto.status_code < 300
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// infrastructure/adapters/http-interactive-demo.adapter.ts
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
import { InteractiveDemoPort } from '../../domain/ports/interactive-demo.port';
|
||||||
|
import { ApiDemoResult, ServerTelemetry } from '../../domain/models/interactive-demo.model';
|
||||||
|
import { ApiExecutionResponseDto, ServerTelemetryDto } from '../dtos/interactive-demo.dto';
|
||||||
|
import { InteractiveDemoMapper } from '../mappers/interactive-demo.mapper';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class HttpInteractiveDemoAdapter implements InteractiveDemoPort {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = '/api/v1/interactive';
|
||||||
|
|
||||||
|
getTelemetry(): Observable<ServerTelemetry> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
return this.http.get<ServerTelemetryDto>(`${this.baseUrl}/telemetry`).pipe(
|
||||||
|
map(dto => {
|
||||||
|
const latency = Math.round(performance.now() - startTime);
|
||||||
|
return InteractiveDemoMapper.toDomainTelemetry(dto, latency);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pingServer(): Observable<ApiDemoResult> {
|
||||||
|
return this.http.get<ApiExecutionResponseDto>(`${this.baseUrl}/ping`).pipe(
|
||||||
|
map(InteractiveDemoMapper.toDomainDemoResult)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
executeCustomDemo(demoId: string): Observable<ApiDemoResult> {
|
||||||
|
return this.http.post<ApiExecutionResponseDto>(`${this.baseUrl}/execute`, { demo_id: demoId }).pipe(
|
||||||
|
map(InteractiveDemoMapper.toDomainDemoResult)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Capa de Aplicación (`application/stores/`)
|
||||||
|
|
||||||
|
Administración de estado reactivo mediante **Angular Signals**.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// application/stores/interactive-api.store.ts
|
||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { InteractiveDemoPort } from '../../domain/ports/interactive-demo.port';
|
||||||
|
import { ApiDemoResult, ServerTelemetry } from '../../domain/models/interactive-demo.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class InteractiveApiStore {
|
||||||
|
private readonly demoPort = inject(InteractiveDemoPort);
|
||||||
|
|
||||||
|
// Signals privadas de estado
|
||||||
|
private readonly _telemetry = signal<ServerTelemetry | null>(null);
|
||||||
|
private readonly _lastResult = signal<ApiDemoResult | null>(null);
|
||||||
|
private readonly _isLoading = signal<boolean>(false);
|
||||||
|
private readonly _errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Signals públicas de solo lectura
|
||||||
|
readonly telemetry = this._telemetry.asReadonly();
|
||||||
|
readonly lastResult = this._lastResult.asReadonly();
|
||||||
|
readonly isLoading = this._isLoading.asReadonly();
|
||||||
|
readonly errorMessage = this._errorMessage.asReadonly();
|
||||||
|
|
||||||
|
readonly isHealthy = computed(() => this._telemetry()?.status === 'Healthy');
|
||||||
|
|
||||||
|
loadTelemetry(): void {
|
||||||
|
this._isLoading.set(true);
|
||||||
|
this._errorMessage.set(null);
|
||||||
|
|
||||||
|
this.demoPort.getTelemetry().subscribe({
|
||||||
|
next: (telemetry) => {
|
||||||
|
this._telemetry.set(telemetry);
|
||||||
|
this._isLoading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this._errorMessage.set(err.message ?? 'No se pudo conectar con el backend .NET');
|
||||||
|
this._isLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
executePing(): void {
|
||||||
|
this._isLoading.set(true);
|
||||||
|
this._errorMessage.set(null);
|
||||||
|
|
||||||
|
this.demoPort.pingServer().subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this._lastResult.set(result);
|
||||||
|
this._isLoading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this._errorMessage.set(err.message ?? 'Error al ejecutar ping');
|
||||||
|
this._isLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 Capa de Presentación (`presentation/`)
|
||||||
|
|
||||||
|
#### A. Smart Page Component
|
||||||
|
```typescript
|
||||||
|
// presentation/features/interactive-api/interactive-api.component.ts
|
||||||
|
import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||||
|
import { InteractiveApiStore } from '../../../application/stores/interactive-api.store';
|
||||||
|
import { JsonPrettyPipe } from '../../../shared/pipes/json-pretty.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-interactive-api',
|
||||||
|
standalone: true,
|
||||||
|
imports: [JsonPrettyPipe],
|
||||||
|
templateUrl: './interactive-api.component.html',
|
||||||
|
styleUrl: './interactive-api.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class InteractiveApiComponent implements OnInit {
|
||||||
|
protected readonly store = inject(InteractiveApiStore);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.store.loadTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTestPing(): void {
|
||||||
|
this.store.executePing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Template con Control Flow Moderno y Estilo Zen
|
||||||
|
```html
|
||||||
|
<!-- presentation/features/interactive-api/interactive-api.component.html -->
|
||||||
|
<section class="zen-card api-tester-container">
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="header-badge">
|
||||||
|
<span class="status-dot" [class.online]="store.isHealthy()"></span>
|
||||||
|
<span class="mono-label">.NET 8 BACKEND // LIVE TESTER</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="section-title">Interacción en Tiempo Real con la API</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="control-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="zen-btn-primary"
|
||||||
|
[disabled]="store.isLoading()"
|
||||||
|
(click)="onTestPing()"
|
||||||
|
>
|
||||||
|
@if (store.isLoading()) {
|
||||||
|
<span>Consultando...</span>
|
||||||
|
} @else {
|
||||||
|
<span>Ejecutar Ping Controlado</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (store.errorMessage(); as error) {
|
||||||
|
<div class="zen-alert-error" role="alert">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (store.lastResult(); as result) {
|
||||||
|
<div class="api-response-viewer">
|
||||||
|
<div class="response-meta">
|
||||||
|
<span class="method-tag">GET</span>
|
||||||
|
<span class="url-tag">{{ result.endpoint }}</span>
|
||||||
|
<span class="status-tag" [class.success]="result.isSuccess">
|
||||||
|
{{ result.httpStatus }} OK
|
||||||
|
</span>
|
||||||
|
<span class="latency-tag">{{ result.executionTimeMs }} ms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre class="json-code"><code>{{ result.payload | jsonPretty }}</code></pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Arquitectura de Módulos Específicos
|
||||||
|
|
||||||
|
### 6.1 Motor de la Terminal Interactiva (`application/services/terminal-executor.service.ts`)
|
||||||
|
Parser de comandos extensible con mapeo declarativo y llamadas HTTP reales al backend:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable, of, isObservable, map } from 'rxjs';
|
||||||
|
import { InteractiveDemoPort } from '../../domain/ports/interactive-demo.port';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TerminalExecutorService {
|
||||||
|
private readonly demoPort = inject(InteractiveDemoPort);
|
||||||
|
|
||||||
|
private readonly commands: Record<string, (args: string[]) => Observable<string> | string> = {
|
||||||
|
help: () => `Comandos disponibles:
|
||||||
|
about - Conoce sobre mi perfil y enfoque arquitectónico
|
||||||
|
skills - Stack técnico (.NET, Angular, MySQL, Clean Arch)
|
||||||
|
projects - Listado de proyectos destacados
|
||||||
|
curl <url> - Ejecuta una petición HTTP en vivo al backend .NET
|
||||||
|
retro - Abre el sistema operativo vintage y videojuegos
|
||||||
|
clear - Limpia el buffer de la consola`,
|
||||||
|
|
||||||
|
about: () => 'Bryan // Fullstack Developer especializado en .NET & Angular Clean Architecture.',
|
||||||
|
skills: () => 'Backend: .NET 8/9, EF Core, MySQL, MediatR, CQRS.\nFrontend: Angular, Signals, OnPush, Canvas 2D.\nPrácticas: Clean Architecture, Ponytail (YAGNI/KISS), TDD.',
|
||||||
|
clear: () => '__CLEAR__',
|
||||||
|
retro: () => 'Abriendo entorno ZenOS...',
|
||||||
|
curl: (args) => {
|
||||||
|
if (args[0] === '/api/v1/interactive/ping' || args[0] === 'ping') {
|
||||||
|
return this.demoPort.pingServer().pipe(
|
||||||
|
map(res => `HTTP ${res.httpStatus} OK (${res.executionTimeMs}ms)\n${JSON.stringify(res.payload, null, 2)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 'Endpoint no reconocido. Prueba con: curl /api/v1/interactive/ping';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execute(rawInput: string): Observable<string> {
|
||||||
|
const parts = rawInput.trim().split(/\s+/);
|
||||||
|
const commandName = parts[0]?.toLowerCase();
|
||||||
|
const args = parts.slice(1);
|
||||||
|
|
||||||
|
if (!commandName) return of('');
|
||||||
|
const handler = this.commands[commandName];
|
||||||
|
if (!handler) return of(`comando no encontrado: ${commandName}. Escribe "help" para ver la lista.`);
|
||||||
|
|
||||||
|
const result = handler(args);
|
||||||
|
return isObservable(result) ? result : of(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Motor del Videojuego Canvas Retro (`retro-game-canvas.component.ts`)
|
||||||
|
Bucle de renderizado nativo en 2D sin librerías pesadas, con limpieza estricta de memoria:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, AfterViewInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-retro-game-canvas',
|
||||||
|
standalone: true,
|
||||||
|
template: `<canvas #gameCanvas width="320" height="240" class="pixel-canvas"></canvas>`,
|
||||||
|
styleUrl: './retro-game-canvas.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class RetroGameCanvasComponent implements AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('gameCanvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
protected readonly score = signal<number>(0);
|
||||||
|
protected readonly isGameOver = signal<boolean>(false);
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
const context = this.canvasRef.nativeElement.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
this.ctx = context;
|
||||||
|
this.ctx.imageSmoothingEnabled = false; // Pixel-art nítido
|
||||||
|
this.isRunning = true;
|
||||||
|
this.gameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private gameLoop = (): void => {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
this.update();
|
||||||
|
this.render();
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.gameLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
private update(): void {
|
||||||
|
// Actualización de entidades y colisiones del juego
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
this.ctx.fillStyle = '#0a0c10';
|
||||||
|
this.ctx.fillRect(0, 0, 320, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.isRunning = false;
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId); // Cero memory leaks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Hero 3D con Scroll Desarmable (`hero-3d.component.ts`)
|
||||||
|
Lectura de scroll pasiva y pausa del render loop mediante `IntersectionObserver`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, AfterViewInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-hero-3d',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './hero-3d.component.html',
|
||||||
|
styleUrl: './hero-3d.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class Hero3dComponent implements AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvasContainer') private containerRef!: ElementRef<HTMLDivElement>;
|
||||||
|
private isVisible = true;
|
||||||
|
private intersectionObserver?: IntersectionObserver;
|
||||||
|
protected scrollProgress = 0;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
||||||
|
this.isVisible = entry.isIntersecting;
|
||||||
|
}, { threshold: 0.05 });
|
||||||
|
this.intersectionObserver.observe(this.containerRef.nativeElement);
|
||||||
|
|
||||||
|
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onScroll = (): void => {
|
||||||
|
if (!this.isVisible) return;
|
||||||
|
const el = this.containerRef.nativeElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
this.scrollProgress = Math.min(Math.max(-rect.top / window.innerHeight, 0), 1);
|
||||||
|
// Aplicar transformación a las piezas 3D según scrollProgress (0.0 a 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
window.removeEventListener('scroll', this.onScroll);
|
||||||
|
this.intersectionObserver?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Interceptores y Configuración Global (`app.config.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// core/interceptors/auth.interceptor.ts
|
||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token && !req.url.includes('/auth/login')) {
|
||||||
|
req = req.clone({
|
||||||
|
setHeaders: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next(req);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app.config.ts
|
||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||||
|
import { errorInterceptor } from './core/interceptors/error.interceptor';
|
||||||
|
import { InteractiveDemoPort } from './domain/ports/interactive-demo.port';
|
||||||
|
import { HttpInteractiveDemoAdapter } from './infrastructure/adapters/http-interactive-demo.adapter';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||||
|
// Inversión de dependencias de puertos
|
||||||
|
{ provide: InteractiveDemoPort, useClass: HttpInteractiveDemoAdapter }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tabla de Anti-Patrones Prohibidos en Frontend
|
||||||
|
|
||||||
|
| Anti-patrón Prohibido | Solución Obligatoria |
|
||||||
|
|---|---|
|
||||||
|
| Inyectar `HttpClient` directamente en componentes | Inyectar Store/Facade que consuma Ports implementados en `infrastructure/` |
|
||||||
|
| Directivas legadas `*ngIf`, `*ngFor` | Sintaxis moderna `@if`, `@for (item of items; track item.id)` |
|
||||||
|
| `ChangeDetectionStrategy.Default` en componentes | Siempre `ChangeDetectionStrategy.OnPush` |
|
||||||
|
| Bucle infinito en canvas del juego al cambiar de ruta | Limpiar bucle con `cancelAnimationFrame` en `ngOnDestroy()` |
|
||||||
|
| Instalar librerías de 1MB para efectos sencillos | Usar Web APIs nativas: Canvas 2D, Web Audio, IntersectionObserver |
|
||||||
|
| Uso de `any` en respuestas HTTP | Definir DTOs tipados en `infrastructure/dtos/` y mapear a modelos |
|
||||||
|
| Estado global mutable o variables sueltas | Angular Signals con interfaces inmutables (`readonly`) |
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
---
|
||||||
|
name: portfolio-ui-design
|
||||||
|
description: Estándar de diseño UI/UX para el Portafolio Profesional Interactivo. Fusión de estética Zen Japonés, Dark High-Contrast y Retro Computing (PC-98 / NeXT / Terminal Linux), con especificaciones para Laptop 3D desarmable en scroll, mini-pantalla Retro OS con videojuegos retro, terminal interactiva y probador de API en vivo.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Estándar de Diseño UI/UX — Portafolio Profesional Interactivo (Zen Japonés + Dark + Retro)
|
||||||
|
|
||||||
|
Este documento define las reglas visuales, estéticas, de interacción y diseño de componentes para el **Portafolio Web Profesional Interactivo**. Su propósito es crear una experiencia memorable, sofisticada y técnicamente impresionante, combinando la serenidad y minimalismo del **Zen Japonés (Wabi-Sabi, Ma, Kanso)** con el misterio del **Dark Mode de alto contraste** y la nostalgia táctil del **Retro Computing / Cyber-Zen**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Filosofía de Diseño — Reglas de Oro
|
||||||
|
|
||||||
|
El agente actúa como un **Director Creativo & Diseñador UI/UX Senior con 10+ años de experiencia** en diseño web interactivo, aplicaciones 3D y sistemas de diseño de alto impacto.
|
||||||
|
|
||||||
|
### 1.1 El Concepto Central: "Cyber-Zen & Retro Elegance"
|
||||||
|
1. **Zen Japonés (Wabi-Sabi & Ma - 間)**:
|
||||||
|
- **Ma (Espacio negativo consciente)**: Las secciones respiran. No atiborrar la pantalla. El vacío tiene peso y propósito.
|
||||||
|
- **Kanso (Simplicidad)**: Eliminar lo superfluo. Cada elemento visual tiene una función de comunicación o interacción.
|
||||||
|
- **Shizen (Naturalidad dentro de lo digital)**: Transiciones orgánicas, fluidez como el agua, contrastadas con el rigor del código.
|
||||||
|
- **Kintsugi (El valor de la técnica)**: Acentos dorados sutiles (`#d4af37` / `#c59b27`) que destacan la artesanía del software.
|
||||||
|
2. **Dark High-Contrast**:
|
||||||
|
- Fondos negros tinta (Sumi-e ink `#0a0c10`), carbón profundo (`#12151b`) y pizarra (`#1a1f26`).
|
||||||
|
- Cero grises desteñidos o descoloridos. El texto y los acentos resaltan con legibilidad WCAG AAA.
|
||||||
|
3. **Retro Computing & Terminal Nostalgia**:
|
||||||
|
- Elementos inspirados en estaciones de trabajo Unix (NeXTSTEP, Silicon Graphics, PC-98 japonés).
|
||||||
|
- Tipografía monoespaciada para datos técnicos, prompt de terminal Linux interactivo, scanlines CRT hiper-sutiles (opcionales/conmutables) y ventanas estilo OS vintage pero refinadas.
|
||||||
|
|
||||||
|
### 1.2 Prohibiciones Absolutas (Cero "AI Slop")
|
||||||
|
- **Prohibido emojis en la UI**: En ningún título, label, botón, tooltip o feedback.
|
||||||
|
- **Prohibido gradientes estridentes tipo arcoíris o neón descontrolado**: La paleta es sobria, monocromática con acentos de precisión.
|
||||||
|
- **Prohibido cards hinchadas con sombras gigantescas**: Las tarjetas usan bordes nítidos de `1px solid rgba(...)`, sombras difusas tenues y proporciones elegantes.
|
||||||
|
- **Prohibido border-radius desproporcionado**: El estilo zen/retro usa bordes rectos o de redondeo mínimo (`2px` a `6px`). Prohibido usar `border-radius: 24px` o píldoras gigantes.
|
||||||
|
- **Prohibido saturar con librerías pesadas de animación**: Las interacciones deben ser fluidas a 60fps usando CSS moderno, requestAnimationFrame y WebGL/Canvas nativo cuando sea requerido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Paleta de Colores y Tokens Visuales
|
||||||
|
|
||||||
|
### 2.1 Variables CSS Base (Sumi & Gold Palette)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Fondos (Sumi-e Ink & Obsidian) */
|
||||||
|
--zen-bg-abyss: #07080b; /* Fondo más profundo (canvas del 3D / Landing) */
|
||||||
|
--zen-bg-base: #0c0e14; /* Fondo principal de la aplicación */
|
||||||
|
--zen-bg-surface: #131720; /* Superficie de cards, ventanas y paneles */
|
||||||
|
--zen-bg-elevated: #1a202c; /* Superficies flotantes, dropdowns, tooltips */
|
||||||
|
--zen-bg-overlay: rgba(12, 14, 20, 0.85); /* Backdrops y overlays */
|
||||||
|
|
||||||
|
/* Bordes & Divisores */
|
||||||
|
--zen-border-subtle: #1e2533; /* Bordes estándar de división */
|
||||||
|
--zen-border-focus: #3d4a63; /* Bordes activos o hover */
|
||||||
|
--zen-border-kintsugi: #c59b27; /* Borde acentuado de oro kintsugi */
|
||||||
|
|
||||||
|
/* Textos & Tipografía */
|
||||||
|
--zen-text-primary: #f0f3f8; /* Blanco perla japonés (alta legibilidad) */
|
||||||
|
--zen-text-secondary: #9aa5b8; /* Gris pizarra para subtítulos y metadatos */
|
||||||
|
--zen-text-muted: #5e697d; /* Gris atenuado para hints y footer */
|
||||||
|
--zen-text-gold: #e2b94a; /* Dorado para llamadas de atención selectas */
|
||||||
|
|
||||||
|
/* Acentos Temáticos */
|
||||||
|
--zen-accent-kintsugi: #c59b27; /* Oro kintsugi (artesanía, acción primaria) */
|
||||||
|
--zen-accent-matcha: #4e9b6f; /* Verde matcha / Terminal éxito */
|
||||||
|
--zen-accent-sakura: #d96f88; /* Rosa sakura atenuado / Alertas o badges */
|
||||||
|
--zen-accent-amber: #e69d45; /* Ámbar CRT retro / Warnings */
|
||||||
|
--zen-accent-cyan: #38bdf8; /* Azul glacial / API tester */
|
||||||
|
|
||||||
|
/* Retro CRT & Terminal */
|
||||||
|
--term-bg: #090b0e;
|
||||||
|
--term-prompt: #4e9b6f; /* Verde fósforo zen */
|
||||||
|
--term-text: #d8dee9;
|
||||||
|
--term-cursor: #e2b94a;
|
||||||
|
|
||||||
|
/* Elevaciones & Sombras */
|
||||||
|
--zen-shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
--zen-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||||
|
--zen-shadow-window: 0 10px 30px rgba(0, 0, 0, 0.8), 0 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
/* Curvaturas (Bordes nítidos y elegantes) */
|
||||||
|
--zen-radius-xs: 2px; /* Botones retro, inputs */
|
||||||
|
--zen-radius-sm: 4px; /* Tarjetas, ventanas */
|
||||||
|
--zen-radius-md: 6px; /* Contenedores principales */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Temas Conmutables (Data-Attributes)
|
||||||
|
|
||||||
|
El usuario puede alternar la paleta visual desde la Terminal (ej: `theme amber`) o desde los ajustes del Retro OS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Tema 1: Zen Dark (Predeterminado) */
|
||||||
|
[data-theme='zen-dark'] {
|
||||||
|
--zen-bg-base: #0c0e14;
|
||||||
|
--zen-bg-surface: #131720;
|
||||||
|
--zen-text-primary: #f0f3f8;
|
||||||
|
--zen-accent-kintsugi: #c59b27;
|
||||||
|
--term-prompt: #4e9b6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tema 2: Retro CRT Ámbar (Estación de trabajo vintage) */
|
||||||
|
[data-theme='retro-amber'] {
|
||||||
|
--zen-bg-base: #0a0702;
|
||||||
|
--zen-bg-surface: #140f06;
|
||||||
|
--zen-border-subtle: #2d1e0a;
|
||||||
|
--zen-text-primary: #ffb833;
|
||||||
|
--zen-text-secondary: #cc8e20;
|
||||||
|
--zen-accent-kintsugi: #ffb833;
|
||||||
|
--term-bg: #0d0903;
|
||||||
|
--term-prompt: #ffb833;
|
||||||
|
--term-text: #ffcf70;
|
||||||
|
--term-cursor: #ffb833;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tema 3: Matrix / Cyber-Zen Verde */
|
||||||
|
[data-theme='matrix-green'] {
|
||||||
|
--zen-bg-base: #040a06;
|
||||||
|
--zen-bg-surface: #0a140d;
|
||||||
|
--zen-border-subtle: #122819;
|
||||||
|
--zen-text-primary: #33ff77;
|
||||||
|
--zen-text-secondary: #22aa4e;
|
||||||
|
--zen-accent-kintsugi: #33ff77;
|
||||||
|
--term-bg: #040a06;
|
||||||
|
--term-prompt: #33ff77;
|
||||||
|
--term-text: #66ff99;
|
||||||
|
--term-cursor: #33ff77;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tipografía
|
||||||
|
|
||||||
|
El portafolio comunica rigor de ingeniería mediante una cuidada jerarquía tipográfica:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Familias Tipográficas */
|
||||||
|
--font-sans: 'Plus Jakarta Sans', 'Inter', -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
--font-retro: 'VT323', 'Press Start 2P', monospace; /* Para detalles retro específicos */
|
||||||
|
```
|
||||||
|
|
||||||
|
| Elemento | Fuente | Tamaño | Peso | Color | Uso |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Display / Hero Title | Sans | `2.75rem - 3.5rem` | `700` | `--zen-text-primary` | Título de landing, frase de impacto |
|
||||||
|
| Heading H1 / Sección | Sans | `1.75rem - 2.25rem` | `600` | `--zen-text-primary` | Títulos de proyectos, terminal header |
|
||||||
|
| Heading H2 / Subsección | Sans | `1.25rem - 1.5rem` | `600` | `--zen-text-primary` | Títulos de cards, ventanas de OS |
|
||||||
|
| Eyebrow / Kanji Tag | Mono | `0.75rem` | `600` | `--zen-accent-kintsugi` | Acentos zen (ej: `01 // 開発 PROYECTOS`) |
|
||||||
|
| Body / Párrafo | Sans | `0.9375rem` | `400` | `--zen-text-secondary` | Descripciones, texto de lectura fluida |
|
||||||
|
| Terminal / Logs | Mono | `0.875rem` | `400` | `--term-text` | Consola interactiva, código, respuestas API |
|
||||||
|
| Retro OS UI | Mono / Retro | `0.8125rem` | `500` | `--zen-text-primary` | Barras de título de ventanas, menú de juegos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Componentes Interiores Clave — Especificación Detallada
|
||||||
|
|
||||||
|
### 4.1 Fondo Hero con Laptop 3D Desarmable al Scroll (Exploded View)
|
||||||
|
|
||||||
|
#### Concepto y Comportamiento:
|
||||||
|
- En la Landing Page, el centro visual es una **laptop estética minimalista** vista en perspectiva isométrica o 3/4.
|
||||||
|
- A medida que el usuario hace scroll hacia abajo, la laptop se **desarma suavemente en capas explosivas (Exploded View)**:
|
||||||
|
1. **Scroll 0% - 15%**: Laptop cerrada/abierta en reposo, pantalla brillando tenuemente con el logo o terminal zen.
|
||||||
|
2. **Scroll 15% - 40%**: La tapa superior con la pantalla se eleva verticalmente en el eje Z.
|
||||||
|
3. **Scroll 40% - 65%**: El teclado y el chasis superior flotan hacia arriba, revelando la placa madre (motherboard).
|
||||||
|
4. **Scroll 65% - 85%**: Los microcomponentes (procesador .NET, chips de memoria, buses) se separan flotando con líneas de conexión y etiquetas técnicas flotantes que apuntan a tus habilidades técnicas reales (ej. "Arquitectura Limpia", "Motor de Signals", "MySQL Optimization").
|
||||||
|
5. **Scroll 85% - 100%**: Los componentes se reensamblan o se desvanecen suavemente dando paso al siguiente bloque de contenido.
|
||||||
|
|
||||||
|
#### Reglas de Implementación Técnica & Rendimiento:
|
||||||
|
- Utilizar **Three.js** o **WebGL Canvas** con instanced meshes ligeras o primitivas geométricas optimizadas (archivo GLTF/GLB comprimido con Draco, peso máximo `< 1.8 MB`).
|
||||||
|
- Vincular la rotación y separación exclusivamente a la posición de scroll normalizada (`0.0` a `1.0`) mediante `requestAnimationFrame`.
|
||||||
|
- **Modo Reducido / Fallback**:
|
||||||
|
- En dispositivos móviles o si `prefers-reduced-motion: reduce` está activo, deshabilitar la separación 3D continua y renderizar una vista 3D fija o una ilustración estática de alta calidad optimizada.
|
||||||
|
- Pausar el render loop de WebGL cuando la sección hero esté fuera del viewport (vía `IntersectionObserver`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Terminal Linux Interactiva (Hacker Zen Console)
|
||||||
|
|
||||||
|
#### Concepto:
|
||||||
|
Una consola interactiva incrustada donde reclutadores y visitantes técnicos pueden ejecutar comandos reales para navegar tu información como si estuvieran en un servidor Unix.
|
||||||
|
|
||||||
|
#### Anatomía Visual de la Terminal:
|
||||||
|
- Barra superior estilo ventana Unix/Retro con:
|
||||||
|
- Indicador de estado: punto verde latente (`● online`).
|
||||||
|
- Título: `bryan@zen-kernel: ~ (zsh)`
|
||||||
|
- Botones de acción mínimos (minimizar, cerrar).
|
||||||
|
- Prompt interactivo: `bryan@zen:~$ ` en color matcha `--term-prompt`.
|
||||||
|
- Cursor parpadeante con animación escalonada `steps(2, start)`.
|
||||||
|
|
||||||
|
#### Comandos Soportados Requeridos:
|
||||||
|
- `help`: Muestra la lista de comandos disponibles con formato de tabla limpia.
|
||||||
|
- `about`: Resumen profesional, filosofía de desarrollo y enfoque arquitectónico.
|
||||||
|
- `skills`: Lista de tecnologías organizadas por Backend (.NET, MySQL), Frontend (Angular, Signals), y Prácticas (Clean Arch, CQRS).
|
||||||
|
- `projects`: Lista de proyectos destacados con enlaces clickeables.
|
||||||
|
- `curl /api/ping`: Ejecuta una petición HTTP real al backend .NET y muestra la respuesta con latencia.
|
||||||
|
- `retro`: Abre la ventana del sistema operativo retro / minijuegos.
|
||||||
|
- `clear`: Limpia el buffer de la pantalla.
|
||||||
|
- `sudo`: Mensaje de humor de permisos para administradores.
|
||||||
|
|
||||||
|
#### Estilo CSS de la Terminal:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.zen-terminal-window {
|
||||||
|
background: var(--term-bg);
|
||||||
|
border: 1px solid var(--zen-border-subtle);
|
||||||
|
border-radius: var(--zen-radius-sm);
|
||||||
|
box-shadow: var(--zen-shadow-window);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-terminal-header {
|
||||||
|
background: #11141a;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-terminal-body {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 260px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--term-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-terminal-prompt {
|
||||||
|
color: var(--term-prompt);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Pantalla de Sistema Operativo Retro (ZenOS / Retro Desktop)
|
||||||
|
|
||||||
|
#### Concepto:
|
||||||
|
Una mini-pantalla o ventana de escritorio que evoca sistemas operativos de los años 90 (estilo NeXTSTEP / PC-98 japonés / System 7):
|
||||||
|
- Fondo de escritorio con cuadrícula punteada retro o patrón geométrico monocromático.
|
||||||
|
- Íconos en el escritorio estilo pixel-art minimalista:
|
||||||
|
- `Juegos.exe` (Abre el catálogo de videojuegos retro).
|
||||||
|
- `Curriculum.doc` (Visor de experiencia interactiva).
|
||||||
|
- `Terminal.sh` (Acceso directo a la consola).
|
||||||
|
- `Ajustes.sys` (Cambio de tema: Zen Dark / Phosphor Amber / Matrix Green).
|
||||||
|
- Ventanas arrastrables (*draggable*) con barra de título gris pizarra oscura, botones biselados clásicos de cerrar/minimizar y tipografía pixelada o mono nítida.
|
||||||
|
|
||||||
|
#### Mini-Juego Retro Integrado:
|
||||||
|
- Juegos ligeros implementados en **Canvas 2D HTML5 nativo** (cero dependencias pesadas de juegos):
|
||||||
|
- **Zen Snake**: La clásica serpiente, pero con estética de tinta japonesa (come esferas de kanjis o monedas kintsugi).
|
||||||
|
- **Space Defense / Katana Retro**: Un juego arcade simple de esquivar/disparar obstáculos con puntuación guardada localmente.
|
||||||
|
- Panel de control del juego: botones de pausa, reinicio, controles táctiles opcionales para móvil y tabla de "High Scores" que puede consultar la API del backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Probador de API en Vivo (Live .NET Playground)
|
||||||
|
|
||||||
|
#### Concepto:
|
||||||
|
Demuestra de inmediato que no es solo un frontend estático, sino una arquitectura fullstack real en producción. Permite al visitante invocar endpoints públicos controlados de .NET.
|
||||||
|
|
||||||
|
#### Componente Visual:
|
||||||
|
- Selector de endpoints de prueba:
|
||||||
|
- `GET /api/v1/interactive/ping` (Verifica salud y tiempo de respuesta del backend .NET).
|
||||||
|
- `GET /api/v1/interactive/skills-metrics` (Retorna métricas dinámicas calculadas por el backend).
|
||||||
|
- `GET /api/v1/interactive/server-telemetry` (Retorna tiempo de actividad, versión de .NET y motor de base de datos MySQL).
|
||||||
|
- Botón de acción: `Ejecutar Endpoint` con feedback de carga elegante.
|
||||||
|
- Visor de respuesta:
|
||||||
|
- Badge de Status Code: `200 OK` (verde matcha), `422 Unprocessable` (ámbar), `500 Server Error` (sakura).
|
||||||
|
- Medidor de latencia: `Tiempo de respuesta: 42 ms`.
|
||||||
|
- Caja de código JSON formateada con sintaxis coloreada nativa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Panel de Administración RBAC (Backoffice Minimalista Zen)
|
||||||
|
|
||||||
|
#### Concepto:
|
||||||
|
Vista protegida para que Bryan pueda gestionar sus proyectos, habilidades y contenidos sin desplegar código nuevo.
|
||||||
|
- Acceso mediante modal discreto (o atajo desde terminal con `login`).
|
||||||
|
- Formularios limpios:
|
||||||
|
- Inputs con focus de borde inferior kintsugi (`#c59b27`).
|
||||||
|
- Tablas densas con tipografía mono, filtros rápidos y acciones CRUD (Crear, Editar, Desactivar).
|
||||||
|
- Modal de confirmación con diseño sobrio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sistema de Espaciado (Grid de 8px)
|
||||||
|
|
||||||
|
Todo padding, margen y separación debe ser múltiplo de `8px` (o submúltiplo de `4px` para elementos microscópicos):
|
||||||
|
|
||||||
|
| Token | Medida | Aplicación |
|
||||||
|
|---|---|---|
|
||||||
|
| `--space-1` | `4px` | Separación mínima entre icono y texto |
|
||||||
|
| `--space-2` | `8px` | Padding interno de inputs, tags y botones compactos |
|
||||||
|
| `--space-3` | `12px` | Separación de items en listas densas |
|
||||||
|
| `--space-4` | `16px` | Padding de cards, ventanas y botones estándar |
|
||||||
|
| `--space-6` | `24px` | Separación entre grupos de campos o elementos de card |
|
||||||
|
| `--space-8` | `32px` | Padding principal de secciones y modales |
|
||||||
|
| `--space-12`| `48px` | Separación entre módulos mayores de la página |
|
||||||
|
| `--space-16`| `64px` | Margen vertical de grandes secciones en desktop |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Movimiento, Animaciones y Microinteracciones
|
||||||
|
|
||||||
|
1. **Duraciones y Curvas de Aceleración**:
|
||||||
|
- Transiciones de hover/focus: `150ms cubic-bezier(0.4, 0, 0.2, 1)` (rápidas, precisas).
|
||||||
|
- Apertura de ventanas retro / modales: `200ms cubic-bezier(0, 0, 0.2, 1)` (sin rebotes infantiles).
|
||||||
|
- Movimiento de scroll 3D: Interpolación lineal suave (*lerp* con factor `0.08` a `0.12`).
|
||||||
|
2. **Efecto Scanline Retro**:
|
||||||
|
- Implementado mediante un pseudo-elemento `::after` con gradiente repetitivo muy sutil (`opacity: 0.04`) y `pointer-events: none`. Con interruptor para apagarlo en caso de preferencia del usuario.
|
||||||
|
3. **Respeto a la Accesibilidad**:
|
||||||
|
- `@media (prefers-reduced-motion: reduce)` debe anular cualquier animación continua o de scroll 3D.
|
||||||
|
|
||||||
|
### 6.2 Diseño de Sonido Retro (Web Audio API Nativa — 0 KB)
|
||||||
|
|
||||||
|
Para brindar respuesta táctil a la terminal y controles retro sin descargar archivos `.mp3` ni añadir librerías:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class RetroAudioService {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
|
||||||
|
private getContext(): AudioContext {
|
||||||
|
if (!this.ctx) this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
return this.ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clic mecánico sutil al teclear en la terminal (120Hz triángulo)
|
||||||
|
playKeypress(): void {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(120, ctx.currentTime);
|
||||||
|
gain.gain.setValueAtTime(0.03, 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.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beep retro al ejecutar comando con éxito (880Hz senoidal)
|
||||||
|
playSuccessBeep(): void {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
||||||
|
gain.gain.setValueAtTime(0.05, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Límites de Líneas y Modularidad de Componentes UI
|
||||||
|
|
||||||
|
Para permitir un desarrollo visual fluido y expresivo sin caer en el extremo de crear micro-archivos innecesarios para cada detalle, se definen los siguientes umbrales tolerables:
|
||||||
|
|
||||||
|
| Elemento Visual | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Plantilla de Componente UI (`.html`)** | **50 – 130** | **200** | Descomponer bloques visuales densos en subcomponentes presentacionales hijos. |
|
||||||
|
| **Estilos de Componente (`.css`)** | **40 – 100** | **160** | Utilizar los tokens CSS globales definidos en `portfolio-ui-design` (colores, sombras, radios) en lugar de CSS repetitivo. |
|
||||||
|
| **Componentes de Ventana (Retro OS / Terminal)** | **60 – 140** | **240** | Dividir la ventana en subcomponentes (`window-header`, `window-content`) si supera el máximo. |
|
||||||
|
| **Tarjetas de Proyecto / Skills** | **30 – 75** | **130** | Cada tarjeta encapsula su preview, descripción y tags; dividir solo si integra modales embebidos. |
|
||||||
|
| **Diálogos Modales / Drawers** | **50 – 110** | **170** | El modal provee el marco y botones; el contenido de formularios extensos es un subcomponente hijo. |
|
||||||
|
|
||||||
|
### Regla de Descomposición UI:
|
||||||
|
1. **Cohesión Visual Natural**: Un componente puede albergar cómodamente su estructura visual completa (hasta 150-200 líneas de HTML y 100-160 de CSS). Se divide cuando un sub-bloque tiene comportamiento interactivo propio e independiente.
|
||||||
|
2. **Subcomponentes Atómicos**: Si una interfaz compuesta (como la consola interactiva o la ventana retro) supera las 200 líneas de HTML o 160 de CSS, se crean subcomponentes hijos en una carpeta `components/` local a esa feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tabla de Anti-Patrones Prohibidos
|
||||||
|
|
||||||
|
| Anti-patrón Prohibido | Solución Correcta en este Portafolio |
|
||||||
|
|---|---|
|
||||||
|
| Usar emojis en la interfaz para verse "amigable" | Tipografía limpia, badges de texto plano e iconos SVG monocromáticos precisos |
|
||||||
|
| Gradientes púrpuras/rosas genéricos de IA | Paleta Sumi-e: fondos carbón profundos con acentos dorados kintsugi y verde matcha |
|
||||||
|
| Border-radius excesivo tipo píldora (20px+) | Bordes nítidos (`2px` a `6px`) de estilo arquitectónico y retro computing |
|
||||||
|
| Ventanas retro sobrecargadas que bloquean la navegación | Ventana contenida, redimensionable o minimizable sin entorpecer el flujo principal |
|
||||||
|
| Modelo 3D pesado que demora 10 segundos en cargar | Geometría optimizada (`< 1.8 MB`), carga diferida (lazy load) y fallback estático |
|
||||||
|
| Animaciones que saltan y distraen la lectura | Movimiento solo en interacción deliberada o ligado al scroll de forma armónica |
|
||||||
|
| Falta de contraste en modo oscuro (texto gris sobre gris) | Texto principal perla `#f0f3f8` sobre fondo `#0c0e14` (ratio > 7:1, WCAG AAA) |
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# ==============================================================================
|
||||||
|
# GITIGNORE GLOBAL — PORTFOLIO (Fullstack .NET + Angular)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Node / Angular Frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# .NET Backend
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
.vs/
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
# IDEs & Editores
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Sistema Operativo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Variables de entorno y secretos locales
|
||||||
|
*.env
|
||||||
|
*.env.local
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Contexto Global del Proyecto
|
||||||
|
|
||||||
|
## Uso Obligatorio de Skills (Ponytail)
|
||||||
|
El entorno tiene instalados los skills oficiales del repositorio Ponytail. Como agente, tienes estrictamente prohibido entregar código final sin antes pasarlo por estas herramientas:
|
||||||
|
1. Cada vez que escribas o modifiques código, debes ejecutar de forma autónoma el skill `ponytail-review` sobre tu propio trabajo.
|
||||||
|
2. Si el skill de Ponytail detecta sobreingeniería, abstracciones innecesarias o código que podría resolverse con la librería estándar de .NET o Angular, debes refactorizarlo inmediatamente antes de darme la respuesta final.
|
||||||
|
|
||||||
|
## Workflows y Habilidades Locales
|
||||||
|
Para saber la estructura exacta del código, lee obligatoriamente las directrices en las siguientes rutas según la tarea:
|
||||||
|
- Backend (.NET Clean Arch): `.agents/skills/portfolio-backend/`
|
||||||
|
- Frontend (Angular): `.agents/skills/portfolio-frontend/`
|
||||||
|
- Diseño UI: `.agents/skills/portfolio-ui-design/`
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
-- ==============================================================================
|
||||||
|
-- BASE DE DATOS: portfolio_db
|
||||||
|
-- MOTOR: MySQL 8.0+ / MariaDB 10.4+ / InnoDB
|
||||||
|
-- CHARSET: utf8mb4 / utf8mb4_unicode_ci
|
||||||
|
-- DESCRIPCIÓN: Esquema relacional minimalista y sin duplicación para
|
||||||
|
-- Portafolio Interactivo (Zen + Retro + Clean Arch .NET & Angular)
|
||||||
|
-- NOTA DBEAVER: Ejecutar todo el archivo con Alt + X (Ejecutar Script SQL / ▶▶).
|
||||||
|
-- No usar Ctrl + Enter sobre múltiples sentencias (error 1064).
|
||||||
|
-- ==============================================================================
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS `portfolio_db`
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE `portfolio_db`;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 1. TABLA: users (Autenticación y RBAC del Administrador)
|
||||||
|
-- Justificación: Almacena la cuenta del propietario (Bryan) para acceder al panel
|
||||||
|
-- administrativo. No se crean tablas complejas de 'roles' o 'permisos' porque
|
||||||
|
-- solo existen 2 roles en el sistema (Admin y Visitor). Un campo 'role' es la
|
||||||
|
-- solución más limpia y minimalista (Ponytail).
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`username` VARCHAR(50) NOT NULL,
|
||||||
|
`email` VARCHAR(100) NOT NULL,
|
||||||
|
`password_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`role` VARCHAR(20) NOT NULL DEFAULT 'Admin',
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at_utc` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_users_username` (`username`),
|
||||||
|
UNIQUE KEY `uq_users_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 2. TABLA: refresh_tokens (Rotación segura de Tokens JWT)
|
||||||
|
-- Justificación: Permite mantener la sesión de administración de Bryan de forma
|
||||||
|
-- segura sin almacenar JWTs de larga duración. Si se detecta un token revocado,
|
||||||
|
-- se invalida la cadena. Relación 1:N con 'users' con borrado en cascada.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `refresh_tokens` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`user_id` CHAR(36) NOT NULL,
|
||||||
|
`token_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`expires_at_utc` DATETIME NOT NULL,
|
||||||
|
`is_revoked` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`replaced_by_token` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_refresh_tokens_user` (`user_id`),
|
||||||
|
KEY `idx_refresh_tokens_validation` (`user_id`, `is_revoked`, `expires_at_utc`),
|
||||||
|
CONSTRAINT `fk_refresh_tokens_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 3. TABLA: skills (Catálogo de Habilidades Técnicas)
|
||||||
|
-- Justificación: Administradas por Bryan vía CRUD. Se renderizan en el frontend
|
||||||
|
-- y se consultan desde el comando 'skills' de la terminal Linux interactiva.
|
||||||
|
-- Normaliza los nombres técnicos para que no se dupliquen strings en los proyectos.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `skills` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
`category` ENUM('Backend', 'Frontend', 'Database', 'DevOps', 'Architecture') NOT NULL,
|
||||||
|
`icon_slug` VARCHAR(50) NULL DEFAULT NULL,
|
||||||
|
`proficiency_percentage` TINYINT UNSIGNED NOT NULL DEFAULT 85,
|
||||||
|
`display_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`is_featured` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_skills_name` (`name`),
|
||||||
|
KEY `idx_skills_category_order` (`category`, `display_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 4. TABLA: projects (Proyectos del Portafolio)
|
||||||
|
-- Justificación: Catálogo de proyectos destacados y proyectos generales. Permite
|
||||||
|
-- a Bryan crear/editar proyectos sin tocar código. Contiene slugs amigables para
|
||||||
|
-- URLs y terminal (`projects show [slug]`), enlaces a repositorios y demos en vivo.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `projects` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`slug` VARCHAR(100) NOT NULL,
|
||||||
|
`title` VARCHAR(150) NOT NULL,
|
||||||
|
`short_description` VARCHAR(255) NOT NULL,
|
||||||
|
`full_description` TEXT NOT NULL,
|
||||||
|
`repository_url` VARCHAR(255) NOT NULL,
|
||||||
|
`live_demo_url` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`thumbnail_url` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`is_featured` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
`display_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at_utc` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_projects_slug` (`slug`),
|
||||||
|
KEY `idx_projects_featured_order` (`is_featured`, `display_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 5. TABLA: project_skills (Tabla Intermedia N:M Proyectos <-> Habilidades)
|
||||||
|
-- Justificación: Evita la duplicación de datos (3FN). Un proyecto usa muchas
|
||||||
|
-- tecnologías y una tecnología está en muchos proyectos. Permite filtrar
|
||||||
|
-- proyectos por tecnología con indexación real sin recurrir a campos de texto
|
||||||
|
-- plano o arrays desnormalizados difíciles de consultar.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `project_skills` (
|
||||||
|
`project_id` CHAR(36) NOT NULL,
|
||||||
|
`skill_id` CHAR(36) NOT NULL,
|
||||||
|
PRIMARY KEY (`project_id`, `skill_id`),
|
||||||
|
KEY `idx_project_skills_skill` (`skill_id`),
|
||||||
|
CONSTRAINT `fk_project_skills_project` FOREIGN KEY (`project_id`)
|
||||||
|
REFERENCES `projects` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_project_skills_skill` FOREIGN KEY (`skill_id`)
|
||||||
|
REFERENCES `skills` (`id`) ON DELETE RESTRICT
|
||||||
|
) 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)
|
||||||
|
-- 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
|
||||||
|
-- y controladas en el backend con almacenamiento persistente.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `interactive_messages` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`sender_name` VARCHAR(60) NOT NULL,
|
||||||
|
`sender_contact` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`message` VARCHAR(500) NOT NULL,
|
||||||
|
`origin` ENUM('Terminal', 'WebForm', 'ApiTester') NOT NULL DEFAULT 'Terminal',
|
||||||
|
`is_approved` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ==============================================================================
|
||||||
|
-- SEED DATA INICIAL (Datos de prueba para arranque inmediato)
|
||||||
|
-- ==============================================================================
|
||||||
|
|
||||||
|
-- 1. Usuario Administrador Inicial (Bryan)
|
||||||
|
-- Contraseña temporal por defecto: 'Admin123*!' (hasheada con BCrypt/PBKDF2 para prueba)
|
||||||
|
INSERT INTO `users` (`id`, `username`, `email`, `password_hash`, `role`)
|
||||||
|
VALUES (
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'bryan',
|
||||||
|
'bryan@portfolio.local',
|
||||||
|
'$2a$11$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
|
||||||
|
'Admin'
|
||||||
|
) ON DUPLICATE KEY UPDATE `username` = `username`;
|
||||||
|
|
||||||
|
-- 2. Habilidades Iniciales
|
||||||
|
INSERT INTO `skills` (`id`, `name`, `category`, `icon_slug`, `proficiency_percentage`, `display_order`, `is_featured`)
|
||||||
|
VALUES
|
||||||
|
('22222222-0001-0000-0000-000000000001', '.NET 8/9 / C#', 'Backend', 'dotnet', 95, 1, TRUE),
|
||||||
|
('22222222-0002-0000-0000-000000000002', 'Clean Architecture & CQRS', 'Architecture', 'architecture', 95, 2, TRUE),
|
||||||
|
('22222222-0003-0000-0000-000000000003', 'Angular & Signals', 'Frontend', 'angular', 90, 3, TRUE),
|
||||||
|
('22222222-0004-0000-0000-000000000004', 'MySQL / EF Core', 'Database', 'mysql', 90, 4, TRUE),
|
||||||
|
('22222222-0005-0000-0000-000000000005', 'Docker & CI/CD', 'DevOps', 'docker', 85, 5, TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE `name` = `name`;
|
||||||
|
|
||||||
|
-- 3. Proyecto Destacado Inicial
|
||||||
|
INSERT INTO `projects` (`id`, `slug`, `title`, `short_description`, `full_description`, `repository_url`, `live_demo_url`, `is_featured`, `display_order`)
|
||||||
|
VALUES (
|
||||||
|
'33333333-0001-0000-0000-000000000001',
|
||||||
|
'portfolio-interactivo-zen',
|
||||||
|
'Portafolio Profesional Interactivo',
|
||||||
|
'Portafolio interactivo con estética Zen Dark, 3D Hero desarmable, terminal Linux y backend .NET Clean Architecture.',
|
||||||
|
'Sistema completo desarrollado con .NET 8/9, EF Core, MySQL, y Angular con Clean Architecture y Signals reactivos.',
|
||||||
|
'https://github.com/bryan/portfolio',
|
||||||
|
'https://portfolio.bryan.dev',
|
||||||
|
TRUE,
|
||||||
|
1
|
||||||
|
) ON DUPLICATE KEY UPDATE `slug` = `slug`;
|
||||||
|
|
||||||
|
-- 4. Vinculación Proyecto - Habilidades
|
||||||
|
INSERT INTO `project_skills` (`project_id`, `skill_id`)
|
||||||
|
VALUES
|
||||||
|
('33333333-0001-0000-0000-000000000001', '22222222-0001-0000-0000-000000000001'),
|
||||||
|
('33333333-0001-0000-0000-000000000001', '22222222-0002-0000-0000-000000000002'),
|
||||||
|
('33333333-0001-0000-0000-000000000001', '22222222-0003-0000-0000-000000000003'),
|
||||||
|
('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
|
||||||
|
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
|
||||||
|
VALUES (
|
||||||
|
UUID(),
|
||||||
|
'Kernel Bot',
|
||||||
|
'system@zen.os',
|
||||||
|
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
|
||||||
|
'Terminal'
|
||||||
|
) ON DUPLICATE KEY UPDATE `sender_name` = `sender_name`;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# PortfolioFrontend
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.26.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"portfolio-frontend": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets",
|
||||||
|
"output": "assets"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "8kB",
|
||||||
|
"maximumError": "16kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "portfolio-frontend:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "portfolio-frontend:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular/build:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "portfolio-frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^20.3.0",
|
||||||
|
"@angular/compiler": "^20.3.0",
|
||||||
|
"@angular/core": "^20.3.0",
|
||||||
|
"@angular/forms": "^20.3.0",
|
||||||
|
"@angular/platform-browser": "^20.3.0",
|
||||||
|
"@angular/router": "^20.3.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^20.3.26",
|
||||||
|
"@angular/cli": "^20.3.26",
|
||||||
|
"@angular/compiler-cli": "^20.3.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"jasmine-core": "~5.9.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |