+```
+
+### DESPUÉS (Propuesto)
+```scss
+// Componentes atómicos bien definidos
+// atoms/_button.scss
+.c-btn { }
+.c-btn--primary { }
+.c-btn--secondary { }
+.c-btn__icon { }
+
+// molecules/_card.scss
+.c-card { }
+.c-card__header { }
+.c-card__body { }
+.c-card__footer { }
+
+// organisms/_dashboard-hero.scss
+.o-dashboard-hero { }
+.o-dashboard-hero__welcome { }
+.o-dashboard-hero__badge { }
+
+// Anidación máxima 2 niveles
+.o-dashboard-hero {
+ &__welcome {
+ // estilos
+ }
+}
+
+// Con utilities
+
+```
+
+---
+
+## 🔍 MÉTRICAS DE ÉXITO
+
+| Métrica | Actual | Objetivo | Mejora |
+|---------|--------|----------|--------|
+| Líneas CSS final | ~8,000 | ~4,000 | -50% |
+| Archivos >500 líneas | 4 | 0 | -100% |
+| Tiempo compilación | ~5s | ~2s | -60% |
+| Colisiones de nombres | ~15 | 0 | -100% |
+| Especificidad promedio | 0-3-2 | 0-1-1 | Mejor |
+| Reutilización código | 40% | 80% | +100% |
+
+---
+
+## 🚦 PRIORIDAD DE IMPLEMENTACIÓN
+
+### 🔴 Crítico (Inmediato)
+1. Dividir `_portfolio.scss` (1,500 líneas)
+2. Dividir `_dashboard.scss` (1,043 líneas)
+3. Eliminar duplicación de `.btn` y `.card`
+4. Establecer nomenclatura BEM
+
+### 🟡 Importante (Corto plazo)
+1. Implementar utilities system
+2. Reorganizar estructura ITCSS
+3. Separar atoms/molecules/organisms
+4. Documentar componentes
+
+### 🟢 Mejora (Mediano plazo)
+1. Code splitting por página
+2. PurgeCSS en producción
+3. Storybook para componentes
+4. Design tokens con Figma
+
+---
+
+## 📚 RECOMENDACIONES ADICIONALES
+
+### Tooling
+- ✅ Implementar **Stylelint** con reglas BEM
+- ✅ **PostCSS** para autoprefixer y optimización
+- ✅ **PurgeCSS** para eliminar CSS no usado
+- ✅ **Storybook** para documentar componentes
+
+### Flujo de Trabajo
+- ✅ Componentizar primero, utilities después
+- ✅ Mobile-first siempre
+- ✅ Dark mode desde diseño inicial
+- ✅ Accesibilidad (WCAG 2.1 AA)
+
+### Performance Budget
+```
+- CSS total: < 50KB (gzipped)
+- Por página: < 20KB (gzipped)
+- Tiempo de compilación: < 2 segundos
+- Lighthouse Score: > 95
+```
+
+---
+
+## 🎓 CONCLUSIÓN
+
+El proyecto tiene una **base sólida** con buen sistema de variables y estructura modular. Sin embargo, sufre de:
+
+1. **Archivos monolíticos** que dificultan mantenimiento
+2. **Nomenclatura inconsistente** que genera conflictos
+3. **Código duplicado** por falta de sistema utility
+4. **Especificidad alta** por anidación excesiva
+
+La implementación de **ITCSS + Atomic Design + BEM + Utilities** transformará el proyecto en un sistema de diseño **profesional, escalable y mantenible**.
+
+**Tiempo estimado de implementación**: 4 semanas
+**ROI esperado**: Mejora del 60% en velocidad de desarrollo
+**Impacto**: Alto - Transformación completa del sistema de estilos
+
+---
+
+**Preparado por**: GitHub Copilot
+**Revisión recomendada**: Equipo de Frontend
+**Próximos pasos**: Aprobar plan y comenzar Fase 1
From 9218a869295398e18e93a593548feefd7ce32c91 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:06:10 +0000
Subject: [PATCH 02/45] =?UTF-8?q?=20Sistema=20de=20Variables=20-=20Sistema?=
=?UTF-8?q?=20de=20Dise=C3=B1o=20CSS/SCSS=20Variables=20USER=20INTERFACE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/assets/stylesheets/_variables.scss | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss
index 2999b4e..987d2f2 100644
--- a/app/assets/stylesheets/_variables.scss
+++ b/app/assets/stylesheets/_variables.scss
@@ -1,9 +1,9 @@
// ============================================
-// Variables - Sistema de Diseño Profesional
+// Sistema de Variables - Sistema de Diseño CSS/SCSS Variables USER INTERFACE
// ============================================
// ============================================
-// COLORES SEMÁNTICOS - REACTIVE MANIFESTO
+// COLORES - REACTIVE MANIFESTO
// ============================================
// RESPONSIVE - Azul/Cyan (Confiabilidad, Fluidez)
@@ -193,6 +193,7 @@ $font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-extrabold: 800;
+$font-weight-black: 900;
// Line Heights (Ritmo Vertical)
$line-height-none: 1;
From d9be7eec2fca43212930d4f7e3f55aedcf9019b0 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:06:28 +0000
Subject: [PATCH 03/45] Main SCSS - Archivo de Entrada Principal
---
app/assets/stylesheets/main.scss | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss
index 911dc16..12d929c 100644
--- a/app/assets/stylesheets/main.scss
+++ b/app/assets/stylesheets/main.scss
@@ -19,6 +19,10 @@
@import 'components/forms';
@import 'components/cards';
@import 'components/alerts';
+@import 'components/auth';
+@import 'components/publication-editor';
+@import 'components/dashboard';
+@import 'components/verification';
@import 'components/hero';
@import 'components/footer';
@import 'components/sections';
From b19143ef3f5883f0153065c42ee5f7c0d1f8455f Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:06:42 +0000
Subject: [PATCH 04/45] Components - Authentication (Login & Register)
---
app/assets/stylesheets/components/_auth.scss | 376 +++++++++++++++++++
1 file changed, 376 insertions(+)
create mode 100644 app/assets/stylesheets/components/_auth.scss
diff --git a/app/assets/stylesheets/components/_auth.scss b/app/assets/stylesheets/components/_auth.scss
new file mode 100644
index 0000000..c16a49f
--- /dev/null
+++ b/app/assets/stylesheets/components/_auth.scss
@@ -0,0 +1,376 @@
+// ============================================
+// Components - Authentication (Login & Register)
+// ============================================
+
+// Auth Page Background
+.auth-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: $spacing-8 $spacing-4;
+ background-color: $gray-50;
+}
+
+// Login & Register Container
+.login-container,
+.register-container {
+ background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(10px);
+ border-radius: $border-radius-xl;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04),
+ 0 1px 4px rgba(0, 0, 0, 0.02);
+ max-width: 440px;
+ width: 100%;
+ overflow: hidden;
+ animation: fadeInUp 0.4s ease-out;
+
+ @keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(15px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+}
+
+// Header Section
+.login-header,
+.register-header {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.05) 0%,
+ rgba(139, 92, 246, 0.05) 100%
+ );
+ padding: $spacing-8 $spacing-6;
+ text-align: center;
+ border-bottom: 1px solid $gray-200;
+
+ .header-icon {
+ font-size: $font-size-4xl;
+ margin-bottom: $spacing-2;
+ display: block;
+ opacity: 0.8;
+ }
+
+ h1 {
+ font-size: $font-size-2xl;
+ font-weight: $font-weight-bold;
+ margin-bottom: $spacing-1;
+ color: $gray-800;
+
+ @include respond-to('md') {
+ font-size: $font-size-3xl;
+ }
+ }
+
+ p {
+ color: $gray-600;
+ font-size: $font-size-sm;
+ font-weight: $font-weight-normal;
+ }
+}
+
+// Body Section
+.login-body,
+.register-body {
+ padding: $spacing-8 $spacing-6;
+ background: $bg-white;
+}
+
+// Tab Container (only for login)
+.tab-container {
+ display: flex;
+ gap: $spacing-2;
+ margin-bottom: $spacing-6;
+ background: $gray-100;
+ padding: 4px;
+ border-radius: $border-radius-md;
+
+ .tab {
+ flex: 1;
+ padding: $spacing-2 $spacing-3;
+ text-align: center;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ font-size: $font-size-sm;
+ font-weight: $font-weight-medium;
+ color: $gray-600;
+ transition: all 0.2s ease;
+ border-radius: calc($border-radius-md - 2px);
+
+ &:hover {
+ color: $gray-900;
+ background: rgba(255, 255, 255, 0.6);
+ }
+
+ &.active {
+ color: $gray-900;
+ background: $bg-white;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ }
+ }
+}
+
+// Form Elements
+.login-body,
+.register-body {
+ .form-group {
+ margin-bottom: $spacing-5;
+
+ label {
+ display: block;
+ margin-bottom: $spacing-2;
+ color: $gray-700;
+ font-weight: $font-weight-medium;
+ font-size: $font-size-sm;
+ }
+
+ input[type="text"],
+ input[type="email"],
+ input[type="password"] {
+ width: 100%;
+ padding: $spacing-3;
+ border: 1px solid $gray-300;
+ border-radius: $border-radius-md;
+ font-size: $font-size-base;
+ transition: all 0.2s ease;
+ background: $bg-white;
+ color: $gray-900;
+
+ &:focus {
+ outline: none;
+ border-color: $gray-400;
+ box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.1);
+ }
+
+ &::placeholder {
+ color: $gray-400;
+ }
+ }
+ }
+
+ .error {
+ background: $gray-50;
+ border: 1px solid $gray-300;
+ border-left: 2px solid $gray-600;
+ color: $gray-700;
+ padding: $spacing-2 $spacing-3;
+ border-radius: $border-radius-sm;
+ margin-bottom: $spacing-4;
+ font-size: $font-size-sm;
+ line-height: $line-height-relaxed;
+ }
+}
+
+// Submit Buttons
+.btn-login,
+.btn-register {
+ width: 100%;
+ padding: $spacing-3;
+ background: $gray-800;
+ color: $text-white;
+ border: none;
+ border-radius: $border-radius-md;
+ font-size: $font-size-base;
+ font-weight: $font-weight-medium;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-top: $spacing-2;
+
+ &:hover {
+ background: $gray-900;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+// Links Section
+.links {
+ text-align: center;
+ margin-top: $spacing-6;
+ padding-top: $spacing-6;
+ border-top: 1px solid $gray-200;
+
+ p {
+ color: $gray-600;
+ margin-bottom: $spacing-2;
+ font-size: $font-size-sm;
+ }
+
+ a {
+ color: $gray-700;
+ text-decoration: none;
+ font-weight: $font-weight-medium;
+ font-size: $font-size-sm;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: $gray-900;
+ text-decoration: underline;
+ }
+ }
+}
+
+// Flash Messages
+.flash-message {
+ padding: $spacing-3;
+ margin-bottom: $spacing-4;
+ border-radius: $border-radius-md;
+ font-size: $font-size-sm;
+ line-height: $line-height-relaxed;
+ border-left: 2px solid;
+
+ &.success {
+ background: rgba(16, 185, 129, 0.06);
+ border-left-color: $resilient-primary;
+ color: #047857;
+ }
+
+ &.error {
+ background: $gray-50;
+ border-left-color: $gray-600;
+ color: $gray-700;
+ }
+
+ &.info {
+ background: rgba(59, 130, 246, 0.06);
+ border-left-color: $responsive-primary;
+ color: #1e40af;
+ }
+}
+
+// Dark Mode
+[data-theme="dark"] {
+ .auth-page {
+ background-color: $dark-bg-secondary;
+ }
+
+ .login-container,
+ .register-container {
+ background: rgba(31, 41, 55, 0.98);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ }
+
+ .login-header,
+ .register-header {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.08) 0%,
+ rgba(139, 92, 246, 0.08) 100%
+ );
+ border-bottom-color: $dark-border-color;
+
+ h1 {
+ color: $dark-text-primary;
+ }
+
+ p {
+ color: $gray-400;
+ }
+ }
+
+ .login-body,
+ .register-body {
+ background: $dark-bg-primary;
+
+ .tab-container {
+ background: $dark-bg-secondary;
+
+ .tab {
+ color: $gray-400;
+
+ &:hover {
+ color: $gray-200;
+ background: rgba(255, 255, 255, 0.05);
+ }
+
+ &.active {
+ color: $gray-200;
+ background: $dark-bg-primary;
+ }
+ }
+ }
+
+ .form-group {
+ label {
+ color: $gray-300;
+ }
+
+ input[type="text"],
+ input[type="email"],
+ input[type="password"] {
+ background: $dark-bg-secondary;
+ border-color: $dark-border-color;
+ color: $dark-text-primary;
+
+ &:focus {
+ border-color: $gray-600;
+ }
+
+ &::placeholder {
+ color: $gray-500;
+ }
+ }
+ }
+
+ .error {
+ background: $dark-bg-secondary;
+ border-color: $dark-border-color;
+ color: $gray-400;
+ }
+ }
+
+ .btn-login,
+ .btn-register {
+ background: $gray-700;
+
+ &:hover {
+ background: $gray-600;
+ }
+ }
+
+ .links {
+ border-top-color: $dark-border-color;
+
+ p {
+ color: $gray-400;
+ }
+
+ a {
+ color: $gray-300;
+
+ &:hover {
+ color: $gray-100;
+ }
+ }
+ }
+
+ .flash-message {
+ &.success {
+ background: rgba(16, 185, 129, 0.1);
+ color: #6ee7b7;
+ }
+
+ &.error {
+ background: $dark-bg-secondary;
+ color: $gray-400;
+ }
+
+ &.info {
+ background: rgba(59, 130, 246, 0.1);
+ color: #93c5fd;
+ }
+ }
+}
From fb2a6a5d8dc49146e55fd5864467acdf55a13918 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:06:54 +0000
Subject: [PATCH 05/45] Components - Dashboard
---
.../stylesheets/components/_dashboard.scss | 1042 +++++++++++++++++
1 file changed, 1042 insertions(+)
create mode 100644 app/assets/stylesheets/components/_dashboard.scss
diff --git a/app/assets/stylesheets/components/_dashboard.scss b/app/assets/stylesheets/components/_dashboard.scss
new file mode 100644
index 0000000..43b81f8
--- /dev/null
+++ b/app/assets/stylesheets/components/_dashboard.scss
@@ -0,0 +1,1042 @@
+// ============================================
+// Components - Dashboard
+// ============================================
+
+// Dashboard Hero Section
+.dashboard-hero {
+ background: linear-gradient(135deg,
+ #0a0e1a 0%,
+ #1a1f35 20%,
+ #2d1b3d 40%,
+ #1e2f47 60%,
+ #0f2027 100%);
+ background-size: 300% 300%;
+ position: relative;
+ padding: $spacing-5xl 0 $spacing-4xl;
+ overflow: hidden;
+ animation: dashboardGradient 20s ease infinite;
+
+ // Efecto de luz ambiental cálida
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at 20% 20%, rgba(245, 158, 11, 0.15) 0%, transparent 50%),
+ radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.15) 0%, transparent 50%),
+ radial-gradient(circle at 50% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 60%);
+ pointer-events: none;
+ animation: warmGlow 8s ease-in-out infinite;
+ }
+
+ // Overlay de gobernanza
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255, 255, 255, 0.01) 2px, rgba(255, 255, 255, 0.01) 4px),
+ repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(255, 255, 255, 0.01) 2px, rgba(255, 255, 255, 0.01) 4px);
+ pointer-events: none;
+ opacity: 0.3;
+ }
+
+ [data-theme="dark"] & {
+ background: linear-gradient(135deg,
+ #000000 0%,
+ #0a0e1a 25%,
+ #1a1035 50%,
+ #0d1b2a 75%,
+ #000814 100%);
+ }
+}
+
+.dashboard-welcome {
+ text-align: center;
+ margin-bottom: $spacing-4xl;
+ position: relative;
+ z-index: 1;
+}
+
+.welcome-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: $spacing-2;
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ padding: $spacing-2 $spacing-4;
+ border-radius: $border-radius-full;
+ margin-bottom: $spacing-4;
+ backdrop-filter: blur(10px);
+ animation: badgePulse 3s ease-in-out infinite;
+
+ [data-theme="dark"] & {
+ background: rgba(245, 158, 11, 0.15);
+ border-color: rgba(245, 158, 11, 0.4);
+ }
+
+ .badge-icon {
+ font-size: $font-size-xl;
+ }
+
+ .badge-text {
+ font-size: $font-size-sm;
+ font-weight: $font-weight-bold;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: #f59e0b;
+
+ [data-theme="dark"] & {
+ color: #fbbf24;
+ }
+ }
+}
+
+.dashboard-title {
+ font-size: clamp(2.5rem, 6vw, 4.5rem);
+ font-weight: $font-weight-black;
+ color: white;
+ margin-bottom: $spacing-4;
+ line-height: 1.1;
+ text-shadow:
+ 0 0 30px rgba(59, 130, 246, 0.5),
+ 0 0 60px rgba(139, 92, 246, 0.3);
+
+ .title-greeting {
+ display: block;
+ font-size: clamp(1rem, 2.5vw, 1.5rem);
+ font-weight: $font-weight-medium;
+ color: rgba(255, 255, 255, 0.7);
+ margin-bottom: $spacing-2;
+ letter-spacing: 0.05em;
+ text-shadow: none;
+ }
+
+ .title-main {
+ display: block;
+ background: linear-gradient(135deg, #60a5fa, #a78bfa, #fbbf24);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ animation: shimmer 3s ease-in-out infinite;
+ }
+}
+
+// Título simplificado sin animaciones excesivas
+.dashboard-title-simple {
+ font-size: clamp(2rem, 5vw, 3.5rem);
+ font-weight: $font-weight-bold;
+ color: white;
+ margin-bottom: $spacing-4;
+ line-height: 1.2;
+
+ .title-greeting {
+ display: block;
+ font-size: clamp(0.875rem, 2vw, 1.125rem);
+ font-weight: $font-weight-medium;
+ color: rgba(255, 255, 255, 0.8);
+ margin-bottom: $spacing-2;
+ letter-spacing: 0.02em;
+ }
+
+ .title-main-simple {
+ display: block;
+ font-weight: $font-weight-extrabold;
+ color: white;
+ }
+}
+
+.dashboard-subtitle {
+ font-size: clamp(1rem, 2.5vw, 1.375rem);
+ color: rgba(255, 255, 255, 0.8);
+ max-width: 700px;
+ margin: 0 auto;
+ line-height: 1.6;
+
+ [data-theme="dark"] & {
+ color: rgba(255, 255, 255, 0.7);
+ }
+}
+
+// Dashboard Grid
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: $spacing-6;
+ margin-bottom: $spacing-5xl;
+ position: relative;
+ z-index: 1;
+
+ @media (max-width: $breakpoint-lg) {
+ grid-template-columns: 1fr;
+ max-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+// Dashboard Card
+.dashboard-card {
+ background: rgba(255, 255, 255, 0.05);
+ backdrop-filter: blur(20px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: $border-radius-2xl;
+ padding: $spacing-6;
+ text-decoration: none;
+ color: white;
+ position: relative;
+ overflow: hidden;
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+ cursor: pointer;
+
+ // Efecto de brillo en hover
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(135deg, transparent 0%, rgba(255, 255, 255, 0.05) 50%, transparent 100%);
+ opacity: 0;
+ transition: opacity 0.4s ease;
+ }
+
+ // Borde animado
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: $border-radius-2xl;
+ padding: 2px;
+ background: linear-gradient(135deg, transparent, transparent);
+ //-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ opacity: 0;
+ transition: opacity 0.4s ease;
+ }
+
+ &:hover {
+ transform: translateY(-8px) scale(1.02);
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.2);
+ box-shadow:
+ 0 20px 40px rgba(0, 0, 0, 0.4),
+ 0 0 80px rgba(59, 130, 246, 0.3);
+
+ &::before {
+ opacity: 1;
+ }
+
+ .card-action {
+ transform: translateX(8px);
+ opacity: 1;
+ }
+
+ .card-icon svg {
+ transform: scale(1.1) rotate(5deg);
+ }
+ }
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.03);
+ border-color: rgba(255, 255, 255, 0.08);
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+ }
+}
+
+// Card color variations
+.card-portfolio {
+ &::after {
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.5), rgba(96, 165, 250, 0.5));
+ }
+
+ &:hover::after {
+ opacity: 1;
+ }
+
+ .card-badge {
+ background: rgba(59, 130, 246, 0.2);
+ color: #60a5fa;
+ }
+
+ .card-icon {
+ background: rgba(59, 130, 246, 0.15);
+ color: #60a5fa;
+ }
+}
+
+.card-publications {
+ &::after {
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.5), rgba(52, 211, 153, 0.5));
+ }
+
+ &:hover::after {
+ opacity: 1;
+ }
+
+ .card-badge {
+ background: rgba(16, 185, 129, 0.2);
+ color: #34d399;
+ }
+
+ .card-icon {
+ background: rgba(16, 185, 129, 0.15);
+ color: #34d399;
+ }
+}
+
+.card-contact {
+ &::after {
+ background: linear-gradient(135deg, rgba(251, 146, 60, 0.5), rgba(251, 191, 36, 0.5));
+ }
+
+ &:hover::after {
+ opacity: 1;
+ }
+
+ .card-badge {
+ background: rgba(251, 146, 60, 0.2);
+ color: #fbbf24;
+ }
+
+ .card-icon {
+ background: rgba(251, 146, 60, 0.15);
+ color: #fbbf24;
+ }
+}
+
+// Card Elements
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: $spacing-4;
+}
+
+.card-icon {
+ width: 56px;
+ height: 56px;
+ border-radius: $border-radius-xl;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+
+ svg {
+ width: 28px;
+ height: 28px;
+ transition: transform 0.3s ease;
+ }
+}
+
+.card-badge {
+ padding: $spacing-1 $spacing-3;
+ border-radius: $border-radius-full;
+ font-size: $font-size-xs;
+ font-weight: $font-weight-bold;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+}
+
+.card-title {
+ font-size: $font-size-2xl;
+ font-weight: $font-weight-bold;
+ color: white;
+ margin-bottom: $spacing-3;
+ line-height: 1.2;
+}
+
+.card-description {
+ font-size: $font-size-base;
+ color: rgba(255, 255, 255, 0.7);
+ line-height: 1.6;
+ margin-bottom: $spacing-5;
+ min-height: 4.8em;
+}
+
+.card-stats {
+ display: flex;
+ gap: $spacing-5;
+ margin-bottom: $spacing-4;
+ padding: $spacing-4 0;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.stat-item {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-1;
+}
+
+.stat-number {
+ font-size: $font-size-2xl;
+ font-weight: $font-weight-bold;
+ color: white;
+ line-height: 1;
+}
+
+.stat-label {
+ font-size: $font-size-xs;
+ color: rgba(255, 255, 255, 0.6);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.card-action {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ font-size: $font-size-sm;
+ font-weight: $font-weight-semibold;
+ color: rgba(255, 255, 255, 0.9);
+ transition: all 0.3s ease;
+ opacity: 0.8;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ transition: transform 0.3s ease;
+ }
+}
+
+// Governance Bar
+.governance-bar {
+ background: linear-gradient(135deg,
+ rgba(16, 185, 129, 0.1) 0%,
+ rgba(34, 211, 238, 0.1) 100%);
+ border: 1px solid rgba(16, 185, 129, 0.2);
+ border-radius: $border-radius-2xl;
+ padding: $spacing-5 $spacing-6;
+ backdrop-filter: blur(20px);
+ position: relative;
+ z-index: 1;
+ overflow: hidden;
+
+ // Efecto de pulso
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg,
+ transparent 0%,
+ rgba(16, 185, 129, 0.1) 50%,
+ transparent 100%);
+ animation: governancePulse 3s ease-in-out infinite;
+ }
+
+ [data-theme="dark"] & {
+ background: linear-gradient(135deg,
+ rgba(16, 185, 129, 0.08) 0%,
+ rgba(34, 211, 238, 0.08) 100%);
+ border-color: rgba(16, 185, 129, 0.25);
+ }
+
+ @media (max-width: $breakpoint-md) {
+ padding: $spacing-4;
+ }
+}
+
+.governance-content {
+ display: flex;
+ align-items: center;
+ gap: $spacing-4;
+ position: relative;
+ z-index: 1;
+
+ @media (max-width: $breakpoint-md) {
+ flex-direction: column;
+ text-align: center;
+ gap: $spacing-3;
+ }
+}
+
+.governance-icon {
+ width: 56px;
+ height: 56px;
+ background: rgba(16, 185, 129, 0.15);
+ border-radius: $border-radius-xl;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+
+ svg {
+ width: 28px;
+ height: 28px;
+ color: #10b981;
+
+ [data-theme="dark"] & {
+ color: #34d399;
+ }
+ }
+
+ @media (max-width: $breakpoint-md) {
+ margin: 0 auto;
+ }
+}
+
+.governance-text {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-1;
+}
+
+.governance-title {
+ font-size: $font-size-lg;
+ font-weight: $font-weight-bold;
+ color: white;
+ line-height: 1.2;
+}
+
+.governance-subtitle {
+ font-size: $font-size-sm;
+ color: rgba(255, 255, 255, 0.7);
+ line-height: 1.4;
+}
+
+.governance-status {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ padding: $spacing-2 $spacing-4;
+ background: rgba(16, 185, 129, 0.15);
+ border-radius: $border-radius-full;
+ flex-shrink: 0;
+
+ @media (max-width: $breakpoint-md) {
+ margin: 0 auto;
+ }
+}
+
+.status-dot {
+ width: 10px;
+ height: 10px;
+ background: #10b981;
+ border-radius: 50%;
+ animation: statusPulse 2s ease-in-out infinite;
+ box-shadow: 0 0 10px rgba(16, 185, 129, 0.8);
+
+ [data-theme="dark"] & {
+ background: #34d399;
+ box-shadow: 0 0 10px rgba(52, 211, 153, 0.8);
+ }
+}
+
+.status-text {
+ font-size: $font-size-sm;
+ font-weight: $font-weight-semibold;
+ color: #10b981;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+
+ [data-theme="dark"] & {
+ color: #34d399;
+ }
+}
+
+// Animations
+@keyframes dashboardGradient {
+ 0%, 100% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+}
+
+@keyframes warmGlow {
+ 0%, 100% { opacity: 0.6; }
+ 50% { opacity: 1; }
+}
+
+@keyframes badgePulse {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.05); }
+}
+
+@keyframes shimmer {
+ 0%, 100% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+}
+
+@keyframes governancePulse {
+ 0%, 100% { transform: translateX(-100%); }
+ 50% { transform: translateX(100%); }
+}
+
+@keyframes statusPulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.7; transform: scale(0.9); }
+}
+
+// User Stats Section
+.user-stats-section {
+ padding: $spacing-5xl 0;
+ background: var(--bg-primary);
+
+ [data-theme="dark"] & {
+ background: var(--bg-primary-dark);
+ }
+}
+
+.stats-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: $spacing-5;
+ margin-bottom: $spacing-5xl;
+
+ @media (max-width: $breakpoint-md) {
+ grid-template-columns: repeat(2, 1fr);
+ gap: $spacing-4;
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.stat-card {
+ background: white;
+ border-radius: $border-radius-xl;
+ padding: $spacing-5;
+ display: flex;
+ align-items: center;
+ gap: $spacing-4;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+ transition: all 0.3s ease;
+ border: 2px solid transparent;
+
+ &:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
+ }
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.05);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
+
+ &:hover {
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
+ }
+ }
+}
+
+.stat-primary {
+ border-color: rgba(59, 130, 246, 0.2);
+
+ .stat-icon {
+ background: rgba(59, 130, 246, 0.1);
+ color: #3b82f6;
+ }
+
+ .stat-value {
+ color: #3b82f6;
+ }
+}
+
+.stat-success {
+ border-color: rgba(16, 185, 129, 0.2);
+
+ .stat-icon {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+ }
+
+ .stat-value {
+ color: #10b981;
+ }
+}
+
+.stat-warning {
+ border-color: rgba(245, 158, 11, 0.2);
+
+ .stat-icon {
+ background: rgba(245, 158, 11, 0.1);
+ color: #f59e0b;
+ }
+
+ .stat-value {
+ color: #f59e0b;
+ }
+}
+
+.stat-info {
+ border-color: rgba(139, 92, 246, 0.2);
+
+ .stat-icon {
+ background: rgba(139, 92, 246, 0.1);
+ color: #8b5cf6;
+ }
+
+ .stat-value {
+ color: #8b5cf6;
+ }
+}
+
+.stat-icon {
+ width: 64px;
+ height: 64px;
+ border-radius: $border-radius-xl;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: $font-size-3xl;
+ flex-shrink: 0;
+}
+
+.stat-content {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-1;
+}
+
+.stat-value {
+ font-size: $font-size-3xl;
+ font-weight: $font-weight-bold;
+ line-height: 1;
+
+ [data-theme="dark"] & {
+ filter: brightness(1.2);
+ }
+}
+
+.stat-label {
+ font-size: $font-size-sm;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+// Activity Chart
+.activity-chart-container {
+ background: white;
+ border-radius: $border-radius-2xl;
+ padding: $spacing-6;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.05);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
+ }
+}
+
+.chart-title {
+ font-size: $font-size-xl;
+ font-weight: $font-weight-bold;
+ color: var(--text-primary);
+ margin-bottom: $spacing-5;
+}
+
+// Quick Actions Section
+.quick-actions-section {
+ padding: $spacing-5xl 0;
+ background: var(--bg-secondary);
+
+ [data-theme="dark"] & {
+ background: var(--bg-secondary-dark);
+ }
+}
+
+.actions-grid {
+ display: flex;
+ gap: $spacing-4;
+ flex-wrap: wrap;
+
+ @media (max-width: $breakpoint-sm) {
+ flex-direction: column;
+ }
+}
+
+.action-item {
+ flex: 1;
+ min-width: 200px;
+ background: white;
+ border-radius: $border-radius-xl;
+ padding: $spacing-5;
+ display: flex;
+ align-items: center;
+ gap: $spacing-3;
+ text-decoration: none;
+ color: var(--text-primary);
+ font-weight: $font-weight-semibold;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+ border: 2px solid transparent;
+
+ &:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
+ border-color: rgba(59, 130, 246, 0.3);
+ }
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.05);
+ color: white;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
+
+ &:hover {
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
+ }
+ }
+}
+
+.action-danger {
+ &:hover {
+ border-color: rgba(239, 68, 68, 0.3);
+ color: #ef4444;
+ }
+}
+
+.action-icon {
+ font-size: $font-size-3xl;
+}
+
+.action-text {
+ font-size: $font-size-lg;
+}
+
+// Favorites Section
+.favorites-section {
+ padding: $spacing-5xl 0;
+ background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
+
+ [data-theme="dark"] & {
+ background: linear-gradient(180deg, var(--bg-primary-dark) 0%, var(--bg-secondary-dark) 100%);
+ }
+}
+
+.favorites-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: $spacing-4;
+
+ @media (max-width: $breakpoint-sm) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.favorite-item {
+ background: white;
+ border-radius: $border-radius-xl;
+ padding: $spacing-5;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: $spacing-3;
+ text-decoration: none;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+ border: 2px solid transparent;
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: linear-gradient(90deg, transparent, currentColor, transparent);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ }
+
+ &:hover {
+ transform: translateY(-8px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
+
+ &::before {
+ opacity: 1;
+ }
+ }
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.05);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
+
+ &:hover {
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
+ }
+ }
+}
+
+.favorite-primary {
+ border-color: rgba(59, 130, 246, 0.2);
+ color: #3b82f6;
+
+ &:hover {
+ border-color: rgba(59, 130, 246, 0.4);
+ }
+}
+
+.favorite-success {
+ border-color: rgba(16, 185, 129, 0.2);
+ color: #10b981;
+
+ &:hover {
+ border-color: rgba(16, 185, 129, 0.4);
+ }
+}
+
+.favorite-warning {
+ border-color: rgba(251, 146, 60, 0.2);
+ color: #fb923c;
+
+ &:hover {
+ border-color: rgba(251, 146, 60, 0.4);
+ }
+}
+
+.favorite-purple {
+ border-color: rgba(139, 92, 246, 0.2);
+ color: #8b5cf6;
+
+ &:hover {
+ border-color: rgba(139, 92, 246, 0.4);
+ }
+}
+
+.favorite-icon {
+ font-size: $font-size-4xl;
+ line-height: 1;
+}
+
+.favorite-title {
+ font-size: $font-size-lg;
+ font-weight: $font-weight-bold;
+ color: var(--text-primary);
+}
+
+.favorite-views {
+ font-size: $font-size-sm;
+ color: var(--text-secondary);
+}
+
+// Learning Progress
+.learning-progress {
+ margin-top: $spacing-6;
+ background: white;
+ border-radius: $border-radius-2xl;
+ padding: $spacing-6;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.05);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
+ }
+}
+
+.progress-title {
+ font-size: $font-size-xl;
+ font-weight: $font-weight-bold;
+ color: var(--text-primary);
+ margin-bottom: $spacing-5;
+}
+
+.progress-grid {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-4;
+}
+
+.progress-item {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-2;
+}
+
+.progress-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.progress-label {
+ font-size: $font-size-base;
+ font-weight: $font-weight-semibold;
+ color: var(--text-primary);
+}
+
+.progress-percentage {
+ font-size: $font-size-sm;
+ font-weight: $font-weight-bold;
+ color: var(--text-secondary);
+}
+
+.progress-bar {
+ width: 100%;
+ height: 12px;
+ background: rgba(0, 0, 0, 0.05);
+ border-radius: $border-radius-full;
+ overflow: hidden;
+ position: relative;
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.1);
+ }
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #3b82f6, #60a5fa);
+ border-radius: $border-radius-full;
+ transition: width 1s ease-in-out;
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ animation: progressShimmer 2s ease-in-out infinite;
+ }
+}
+
+.progress-fill-success {
+ background: linear-gradient(90deg, #10b981, #34d399);
+}
+
+.progress-fill-warning {
+ background: linear-gradient(90deg, #fb923c, #fbbf24);
+}
+
+// Action items variations
+.action-home {
+ &:hover {
+ border-color: rgba(59, 130, 246, 0.4);
+
+ .action-icon {
+ transform: scale(1.1);
+ }
+ }
+}
+
+.action-profile {
+ &:hover {
+ border-color: rgba(139, 92, 246, 0.4);
+
+ .action-icon {
+ transform: scale(1.1);
+ }
+ }
+}
+
+.action-contact {
+ &:hover {
+ border-color: rgba(16, 185, 129, 0.4);
+
+ .action-icon {
+ transform: scale(1.1);
+ }
+ }
+}
+
+@keyframes progressShimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
From 63a82bb856045b88a185be29351e3a03969f103c Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:07:13 +0000
Subject: [PATCH 06/45] Components - Portfolio (Innovative Design)
---
.../stylesheets/components/_portfolio.scss | 457 ++++++++++++++++++
1 file changed, 457 insertions(+)
diff --git a/app/assets/stylesheets/components/_portfolio.scss b/app/assets/stylesheets/components/_portfolio.scss
index 1231afc..c3e0c33 100644
--- a/app/assets/stylesheets/components/_portfolio.scss
+++ b/app/assets/stylesheets/components/_portfolio.scss
@@ -709,3 +709,460 @@
}
}
}
+
+// ============================================
+// Auth Modal - Sistema de Protección
+// ============================================
+
+.auth-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: $z-modal;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ &.active {
+ opacity: 1;
+
+ .auth-modal-content {
+ transform: scale(1) translateY(0);
+ opacity: 1;
+ }
+ }
+}
+
+.auth-modal-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(15px);
+ -webkit-backdrop-filter: blur(15px);
+}
+
+.auth-modal-content {
+ position: relative;
+ max-width: 560px;
+ width: calc(100% - 2rem);
+ max-height: calc(100vh - 2rem);
+ background: #ffffff;
+ border-radius: $border-radius-xl;
+ box-shadow:
+ 0 25px 50px -12px rgba(0, 0, 0, 0.5),
+ 0 0 120px rgba(59, 130, 246, 0.4),
+ 0 0 0 1px rgba(255, 255, 255, 0.1);
+ transform: scale(0.9) translateY(20px);
+ opacity: 0;
+ transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ [data-theme="dark"] & {
+ background: #1e293b;
+ border: 1px solid rgba(59, 130, 246, 0.4);
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ width: calc(100% - 1rem);
+ max-height: calc(100vh - 1rem);
+ border-radius: $border-radius-lg;
+ }
+
+ // Gradient top border
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: linear-gradient(90deg, #3b82f6, #06b6d4, #8b5cf6);
+ background-size: 200% 100%;
+ animation: gradientShift 3s ease infinite;
+ }
+}
+
+.auth-modal-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ width: 40px;
+ height: 40px;
+ border: none;
+ background: rgba(0, 0, 0, 0.05);
+ color: var(--text-muted);
+ font-size: 1.5rem;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ z-index: 1;
+
+ &:hover {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ transform: rotate(90deg);
+ }
+
+ [data-theme="dark"] & {
+ background: rgba(255, 255, 255, 0.1);
+
+ &:hover {
+ background: rgba(239, 68, 68, 0.2);
+ }
+ }
+}
+
+.auth-modal-header {
+ padding: $spacing-6 $spacing-5 $spacing-5;
+ text-align: center;
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.03) 0%,
+ rgba(139, 92, 246, 0.03) 100%);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+
+ [data-theme="dark"] & {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.08) 0%,
+ rgba(139, 92, 246, 0.08) 100%);
+ border-bottom-color: rgba(255, 255, 255, 0.08);
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ padding: $spacing-5 $spacing-4 $spacing-4;
+ }
+}
+
+.auth-modal-icon {
+ font-size: clamp(2.5rem, 8vw, 3.5rem);
+ margin-bottom: $spacing-2;
+ filter: drop-shadow(0 4px 12px rgba(245, 158, 11, 0.3));
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.1);
+ opacity: 0.8;
+ }
+}
+
+.auth-modal-title {
+ font-size: clamp(1.5rem, 5vw, 2.441rem);
+ font-weight: $font-weight-extrabold;
+ color: #1f2937;
+ margin-bottom: $spacing-2;
+ letter-spacing: -0.025em;
+ line-height: 1.2;
+
+ [data-theme="dark"] & {
+ color: #f9fafb;
+ }
+}
+
+.auth-modal-subtitle {
+ font-size: clamp(0.875rem, 3vw, 1.125rem);
+ line-height: 1.5;
+ color: #6b7280;
+ margin: 0;
+ padding: 0 $spacing-2;
+
+ [data-theme="dark"] & {
+ color: #9ca3af;
+ }
+}
+
+.auth-modal-body {
+ padding: $spacing-5 $spacing-5 $spacing-6;
+
+ @media (max-width: $breakpoint-sm) {
+ padding: $spacing-4 $spacing-3 $spacing-5;
+ }
+}
+
+.auth-modal-info {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.1) 0%,
+ rgba(139, 92, 246, 0.1) 100%);
+ border-radius: $border-radius-lg;
+ padding: $spacing-4;
+ margin-bottom: $spacing-5;
+ border: 2px solid rgba(59, 130, 246, 0.2);
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ background: linear-gradient(180deg, #3b82f6, #8b5cf6);
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ padding: $spacing-3;
+ margin-bottom: $spacing-4;
+ border-radius: $border-radius-md;
+ }
+
+ [data-theme="dark"] & {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.15) 0%,
+ rgba(139, 92, 246, 0.15) 100%);
+ border-color: rgba(59, 130, 246, 0.3);
+ }
+
+ p {
+ margin: 0;
+ font-size: clamp(0.875rem, 2.5vw, 1rem);
+ line-height: 1.5;
+ color: #4b5563;
+
+ [data-theme="dark"] & {
+ color: #d1d5db;
+ }
+
+ &:first-child {
+ margin-bottom: $spacing-2;
+ }
+
+ strong {
+ color: #1f2937;
+ font-weight: $font-weight-bold;
+ font-size: clamp(0.75rem, 2vw, 0.875rem);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ display: block;
+ margin-bottom: $spacing-1;
+
+ [data-theme="dark"] & {
+ color: #f9fafb;
+ }
+ }
+
+ span {
+ color: #3b82f6;
+ font-weight: $font-weight-semibold;
+ font-size: clamp(1rem, 3vw, 1.25rem);
+ display: block;
+ margin-top: $spacing-1;
+ word-break: break-word;
+
+ [data-theme="dark"] & {
+ color: #60a5fa;
+ }
+ }
+ }
+}
+
+.auth-modal-benefits {
+ margin-bottom: $spacing-5;
+ background: rgba(16, 185, 129, 0.03);
+ padding: $spacing-4;
+ border-radius: $border-radius-lg;
+ border: 1px solid rgba(16, 185, 129, 0.1);
+
+ [data-theme="dark"] & {
+ background: rgba(16, 185, 129, 0.08);
+ border-color: rgba(16, 185, 129, 0.2);
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ padding: $spacing-3;
+ margin-bottom: $spacing-4;
+ border-radius: $border-radius-md;
+ }
+
+ h4 {
+ font-size: clamp(0.9375rem, 3vw, 1.25rem);
+ font-weight: $font-weight-bold;
+ color: #1f2937;
+ margin-bottom: $spacing-3;
+
+ [data-theme="dark"] & {
+ color: #f9fafb;
+ }
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+
+ li {
+ padding: $spacing-2 0;
+ font-size: clamp(0.875rem, 2.5vw, 1rem);
+ line-height: 1.5;
+ color: #4b5563;
+ display: flex;
+ align-items: flex-start;
+ gap: $spacing-2;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+
+ [data-theme="dark"] & {
+ color: #d1d5db;
+ border-bottom-color: rgba(255, 255, 255, 0.05);
+ }
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
+ &::before {
+ content: none;
+ }
+ }
+ }
+}
+
+.auth-modal-actions {
+ display: flex;
+ gap: $spacing-3;
+ margin-bottom: $spacing-4;
+
+ .btn {
+ flex: 1;
+ justify-content: center;
+ padding: $spacing-3 $spacing-4;
+ font-size: clamp(0.875rem, 2.5vw, 1rem);
+ white-space: nowrap;
+ }
+
+ @media (max-width: $breakpoint-md) {
+ gap: $spacing-2;
+
+ .btn {
+ padding: $spacing-3 $spacing-3;
+ font-size: 0.875rem;
+ }
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ flex-direction: column;
+ gap: $spacing-2;
+
+ .btn {
+ width: 100%;
+ padding: $spacing-3 $spacing-4;
+ font-size: 1rem;
+ }
+ }
+}
+
+.auth-modal-footer {
+ text-align: center;
+ padding-top: $spacing-4;
+ margin-top: $spacing-4;
+ border-top: 2px dashed rgba(0, 0, 0, 0.08);
+
+ [data-theme="dark"] & {
+ border-top-color: rgba(255, 255, 255, 0.08);
+ }
+
+ @media (max-width: $breakpoint-sm) {
+ padding-top: $spacing-3;
+ margin-top: $spacing-3;
+ }
+
+ p {
+ font-size: clamp(0.875rem, 2.5vw, 1rem);
+ line-height: 1.5;
+ color: #6b7280;
+ margin: 0;
+ padding: 0 $spacing-2;
+
+ [data-theme="dark"] & {
+ color: #9ca3af;
+ }
+
+ strong {
+ color: #10b981;
+ font-weight: $font-weight-bold;
+
+ [data-theme="dark"] & {
+ color: #34d399;
+ }
+ }
+ }
+}
+
+// Hero notices
+.hero-welcome {
+ margin-top: $spacing-3;
+ padding: $spacing-3 $spacing-4;
+ background: linear-gradient(135deg,
+ rgba(16, 185, 129, 0.1) 0%,
+ rgba(6, 182, 212, 0.1) 100%);
+ border-radius: $border-radius-lg;
+ border: 1px solid rgba(16, 185, 129, 0.2);
+ color: var(--text-primary);
+ font-size: $font-size-base;
+
+ [data-theme="dark"] & {
+ background: linear-gradient(135deg,
+ rgba(16, 185, 129, 0.15) 0%,
+ rgba(6, 182, 212, 0.15) 100%);
+ border-color: rgba(16, 185, 129, 0.3);
+ }
+
+ strong {
+ color: #10b981;
+ }
+}
+
+.hero-notice {
+ margin-top: $spacing-3;
+ padding: $spacing-3 $spacing-4;
+ background: linear-gradient(135deg,
+ rgba(245, 158, 11, 0.1) 0%,
+ rgba(239, 68, 68, 0.1) 100%);
+ border-radius: $border-radius-lg;
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ color: var(--text-primary);
+ font-size: $font-size-base;
+
+ [data-theme="dark"] & {
+ background: linear-gradient(135deg,
+ rgba(245, 158, 11, 0.15) 0%,
+ rgba(239, 68, 68, 0.15) 100%);
+ border-color: rgba(245, 158, 11, 0.4);
+ }
+
+ strong {
+ color: #f59e0b;
+ }
+}
+
+// Protected link styling
+.protected-link {
+ position: relative;
+
+ &[data-auth="false"] {
+ &::after {
+ content: '🔒';
+ margin-left: 0.5rem;
+ opacity: 0.6;
+ }
+
+ &:hover::after {
+ opacity: 1;
+ }
+ }
+}
From 06d67fc7076b3a2c6581f684b74c20978970dd59 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:07:24 +0000
Subject: [PATCH 07/45] Components - Publication Editor & Forms
---
.../components/_publication-editor.scss | 619 ++++++++++++++++++
1 file changed, 619 insertions(+)
create mode 100644 app/assets/stylesheets/components/_publication-editor.scss
diff --git a/app/assets/stylesheets/components/_publication-editor.scss b/app/assets/stylesheets/components/_publication-editor.scss
new file mode 100644
index 0000000..fb7e0f5
--- /dev/null
+++ b/app/assets/stylesheets/components/_publication-editor.scss
@@ -0,0 +1,619 @@
+// ============================================
+// Components - Publication Editor & Forms
+// ============================================
+
+// Publication Form Page
+.publication-page {
+ min-height: 100vh;
+ background: var(--bg-secondary);
+}
+
+// Publication Navbar
+.publication-navbar {
+ background: var(--bg-white);
+ box-shadow: var(--shadow-sm);
+ padding: $spacing-5 $spacing-8;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: $z-sticky;
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--border-color);
+
+ h1 {
+ font-size: $font-size-2xl;
+ color: var(--text-primary);
+ font-weight: $font-weight-bold;
+ margin: 0;
+
+ @include respond-to('md') {
+ font-size: $font-size-3xl;
+ }
+ }
+
+ a {
+ color: var(--responsive-primary);
+ text-decoration: none;
+ font-weight: $font-weight-semibold;
+ transition: all $transition-base;
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+
+ &:hover {
+ color: var(--responsive-dark);
+ transform: translateX(-3px);
+ }
+ }
+}
+
+// Publication Form Container
+.publication-form-container {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: $spacing-10 $spacing-4;
+
+ @include respond-to('md') {
+ padding: $spacing-12 $spacing-8;
+ }
+}
+
+// Form Card
+.form-card {
+ background: var(--bg-white);
+ padding: $spacing-10;
+ border-radius: $border-radius-2xl;
+ box-shadow: var(--shadow-lg);
+ border: 1px solid var(--border-color);
+ transition: all $transition-slow;
+
+ &:hover {
+ box-shadow: var(--shadow-xl);
+ }
+
+ @include respond-to('md') {
+ padding: $spacing-12;
+ }
+
+ h2 {
+ font-size: $font-size-4xl;
+ color: var(--text-primary);
+ margin-bottom: $spacing-10;
+ font-weight: $font-weight-extrabold;
+ background: var(--responsive-gradient);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ letter-spacing: $tracking-tight;
+ line-height: $line-height-tight;
+ }
+}
+
+// Publication Form Groups
+.publication-form {
+ .form-group {
+ margin-bottom: $spacing-8;
+ animation: fadeInUp 0.5s ease-out backwards;
+
+ @for $i from 1 through 10 {
+ &:nth-child(#{$i}) {
+ animation-delay: #{$i * 0.05}s;
+ }
+ }
+
+ @keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ label {
+ display: block;
+ margin-bottom: $spacing-3;
+ color: var(--text-primary);
+ font-weight: $font-weight-bold;
+ font-size: $font-size-sm;
+ text-transform: uppercase;
+ letter-spacing: $tracking-wider;
+ transition: color $transition-base;
+
+ .required {
+ color: var(--error-color);
+ margin-left: $spacing-1;
+ font-size: $font-size-base;
+ }
+ }
+
+ input[type="text"],
+ input[type="url"],
+ textarea,
+ select {
+ width: 100%;
+ padding: $spacing-4 $spacing-5;
+ border: 2px solid var(--border-color);
+ border-radius: $border-radius-lg;
+ font-size: $font-size-base;
+ transition: all $transition-base $easing-smooth;
+ background: var(--bg-white);
+ color: var(--text-primary);
+ font-family: $font-family-base;
+ font-weight: $font-weight-medium;
+ line-height: $line-height-relaxed;
+
+ &:hover {
+ border-color: var(--border-color-dark);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--responsive-primary);
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1),
+ 0 4px 16px rgba(59, 130, 246, 0.12);
+ transform: translateY(-1px);
+ }
+
+ &::placeholder {
+ color: var(--text-muted);
+ font-weight: $font-weight-normal;
+ }
+ }
+
+ // Textarea specific
+ textarea {
+ min-height: 400px;
+ resize: vertical;
+ font-family: $font-family-mono;
+ font-size: $font-size-sm;
+ line-height: $line-height-loose;
+ padding: $spacing-6;
+
+ @include respond-to('md') {
+ min-height: 500px;
+ }
+ }
+
+ // Select specific
+ select {
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%236b7280' d='M8 11L3 6h10z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right $spacing-4 center;
+ padding-right: $spacing-12;
+
+ &:hover {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%233b82f6' d='M8 11L3 6h10z'/%3E%3C/svg%3E");
+ }
+ }
+
+ // Character counter
+ .char-counter {
+ font-size: $font-size-xs;
+ color: var(--text-muted);
+ text-align: right;
+ margin-top: $spacing-2;
+ font-weight: $font-weight-medium;
+
+ &.warning {
+ color: var(--warning-color);
+ }
+
+ &.error {
+ color: var(--error-color);
+ }
+ }
+
+ .help-text {
+ font-size: $font-size-sm;
+ color: var(--text-secondary);
+ margin-top: $spacing-3;
+ line-height: $line-height-relaxed;
+ padding: $spacing-3 $spacing-4;
+ background: rgba(59, 130, 246, 0.05);
+ border-left: 3px solid var(--responsive-primary);
+ border-radius: $border-radius-md;
+
+ strong {
+ color: var(--text-primary);
+ font-weight: $font-weight-semibold;
+ }
+
+ code {
+ background: rgba(0, 0, 0, 0.05);
+ padding: $spacing-1 $spacing-2;
+ border-radius: $border-radius-sm;
+ font-family: $font-family-mono;
+ font-size: $font-size-xs;
+ }
+ }
+
+ .error {
+ background: rgba(239, 68, 68, 0.08);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-left: 4px solid var(--error-color);
+ color: #991b1b;
+ padding: $spacing-4;
+ border-radius: $border-radius-md;
+ margin-top: $spacing-3;
+ font-size: $font-size-sm;
+ font-weight: $font-weight-medium;
+ animation: shake 0.4s ease-in-out;
+ }
+ }
+
+ // Two column layout for shorter fields
+ .form-row {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: $spacing-6;
+
+ @include respond-to('md') {
+ grid-template-columns: 1fr 1fr;
+ }
+ }
+
+ // Tags input special styling
+ .tags-input-wrapper {
+ position: relative;
+
+ input {
+ padding-right: $spacing-12;
+ }
+
+ .tag-suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--bg-white);
+ border: 2px solid var(--responsive-primary);
+ border-top: none;
+ border-radius: 0 0 $border-radius-lg $border-radius-lg;
+ max-height: 200px;
+ overflow-y: auto;
+ z-index: $z-dropdown;
+ box-shadow: var(--shadow-lg);
+
+ .tag-suggestion {
+ padding: $spacing-3 $spacing-4;
+ cursor: pointer;
+ transition: all $transition-fast;
+ font-size: $font-size-sm;
+ border-bottom: 1px solid var(--border-color);
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ background: rgba(59, 130, 246, 0.08);
+ color: var(--responsive-primary);
+ }
+ }
+ }
+
+ .tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $spacing-2;
+ margin-top: $spacing-3;
+
+ .tag {
+ display: inline-flex;
+ align-items: center;
+ gap: $spacing-2;
+ padding: $spacing-2 $spacing-3;
+ background: var(--responsive-gradient);
+ color: white;
+ border-radius: $border-radius-full;
+ font-size: $font-size-xs;
+ font-weight: $font-weight-semibold;
+ animation: tagPop 0.3s ease-out;
+
+ @keyframes tagPop {
+ 0% {
+ transform: scale(0);
+ opacity: 0;
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+
+ .tag-remove {
+ cursor: pointer;
+ padding: 0 $spacing-1;
+ transition: transform $transition-fast;
+
+ &:hover {
+ transform: scale(1.3) rotate(90deg);
+ }
+ }
+ }
+ }
+ }
+}
+
+// Form Actions
+.form-actions {
+ display: flex;
+ gap: $spacing-4;
+ margin-top: $spacing-10;
+ padding-top: $spacing-10;
+ border-top: 2px solid var(--border-color);
+ flex-direction: column;
+
+ @include respond-to('sm') {
+ flex-direction: row;
+ }
+
+ .btn {
+ padding: $spacing-5 $spacing-8;
+ border-radius: $border-radius-lg;
+ font-size: $font-size-md;
+ font-weight: $font-weight-bold;
+ cursor: pointer;
+ transition: all $transition-base $easing-smooth;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: $spacing-2;
+ border: none;
+ text-transform: uppercase;
+ letter-spacing: $tracking-wide;
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.3);
+ transform: translate(-50%, -50%);
+ transition: width 0.6s, height 0.6s;
+ }
+
+ &:hover::before {
+ width: 300px;
+ height: 300px;
+ }
+
+ &-primary {
+ background: var(--responsive-gradient);
+ color: white;
+ box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
+
+ &:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4),
+ 0 4px 12px rgba(59, 130, 246, 0.2);
+ }
+
+ &:active {
+ transform: translateY(-1px);
+ }
+ }
+
+ &-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 2px solid var(--border-color);
+
+ &:hover {
+ background: var(--bg-secondary);
+ border-color: var(--border-color-dark);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+ }
+ }
+
+ &-danger {
+ background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
+ color: white;
+ box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
+
+ &:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
+ }
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none !important;
+ }
+
+ svg, .icon {
+ width: 20px;
+ height: 20px;
+ }
+ }
+}
+
+// Publication Info Box (Status, Stats)
+.publication-info {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.1) 0%,
+ rgba(139, 92, 246, 0.1) 100%
+ );
+ border: 2px solid var(--responsive-light);
+ border-radius: $border-radius-xl;
+ padding: $spacing-6;
+ margin-bottom: $spacing-8;
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ right: -50%;
+ width: 100%;
+ height: 200%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.1), transparent 70%);
+ }
+
+ .info-header {
+ display: flex;
+ align-items: center;
+ gap: $spacing-3;
+ margin-bottom: $spacing-4;
+
+ .icon {
+ font-size: $font-size-3xl;
+ }
+
+ h3 {
+ font-size: $font-size-xl;
+ font-weight: $font-weight-bold;
+ color: var(--text-primary);
+ margin: 0;
+ }
+ }
+
+ .info-content {
+ position: relative;
+ z-index: 1;
+
+ p {
+ margin-bottom: $spacing-3;
+ color: var(--text-secondary);
+ line-height: $line-height-relaxed;
+
+ strong {
+ color: var(--text-primary);
+ font-weight: $font-weight-semibold;
+ }
+ }
+ }
+
+ .status-badge {
+ display: inline-block;
+ padding: $spacing-2 $spacing-4;
+ border-radius: $border-radius-full;
+ font-size: $font-size-xs;
+ font-weight: $font-weight-bold;
+ text-transform: uppercase;
+ letter-spacing: $tracking-wide;
+
+ &.draft {
+ background: rgba(156, 163, 175, 0.2);
+ color: #4b5563;
+ }
+
+ &.pending {
+ background: rgba(245, 158, 11, 0.2);
+ color: #92400e;
+ }
+
+ &.approved {
+ background: rgba(16, 185, 129, 0.2);
+ color: #065f46;
+ }
+
+ &.rejected {
+ background: rgba(239, 68, 68, 0.2);
+ color: #991b1b;
+ }
+ }
+}
+
+// Preview Button
+.preview-toggle {
+ position: fixed;
+ bottom: $spacing-8;
+ right: $spacing-8;
+ background: var(--message-gradient);
+ color: white;
+ border: none;
+ border-radius: $border-radius-full;
+ padding: $spacing-5 $spacing-8;
+ font-size: $font-size-base;
+ font-weight: $font-weight-bold;
+ cursor: pointer;
+ box-shadow: $shadow-xl;
+ transition: all $transition-base;
+ z-index: $z-fixed;
+
+ &:hover {
+ transform: translateY(-5px) scale(1.05);
+ box-shadow: $shadow-2xl;
+ }
+
+ &:active {
+ transform: translateY(-2px) scale(1.02);
+ }
+}
+
+// Dark Mode
+[data-theme="dark"] {
+ .publication-navbar {
+ background: var(--dark-bg-primary);
+ border-bottom-color: var(--dark-border-color);
+ }
+
+ .form-card {
+ background: var(--dark-bg-primary);
+ border-color: var(--dark-border-color);
+ }
+
+ .publication-form {
+ .form-group {
+ input,
+ textarea,
+ select {
+ background: var(--dark-bg-secondary);
+ border-color: var(--dark-border-color);
+ color: var(--dark-text-primary);
+
+ &:focus {
+ background: var(--dark-bg-secondary);
+ }
+ }
+
+ .help-text {
+ background: rgba(59, 130, 246, 0.1);
+ border-left-color: var(--responsive-light);
+
+ code {
+ background: rgba(255, 255, 255, 0.1);
+ }
+ }
+ }
+ }
+
+ .publication-info {
+ background: linear-gradient(135deg,
+ rgba(59, 130, 246, 0.15) 0%,
+ rgba(139, 92, 246, 0.15) 100%
+ );
+ border-color: var(--responsive-primary);
+ }
+
+ .form-actions .btn-secondary {
+ background: var(--dark-bg-secondary);
+ border-color: var(--dark-border-color);
+ color: var(--dark-text-primary);
+
+ &:hover {
+ background: var(--dark-bg-primary);
+ }
+ }
+}
From e34f15061ca72b0d7c2f81961c8b12ac730aa939 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:07:35 +0000
Subject: [PATCH 08/45] Components - Email Verification
---
.../stylesheets/components/_verification.scss | 193 ++++++++++++++++++
1 file changed, 193 insertions(+)
create mode 100644 app/assets/stylesheets/components/_verification.scss
diff --git a/app/assets/stylesheets/components/_verification.scss b/app/assets/stylesheets/components/_verification.scss
new file mode 100644
index 0000000..637f446
--- /dev/null
+++ b/app/assets/stylesheets/components/_verification.scss
@@ -0,0 +1,193 @@
+// ============================================
+// Components - Email Verification
+// ============================================
+
+.verification-section {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ padding: $spacing-5xl 0;
+ display: flex;
+ align-items: center;
+}
+
+.verification-container {
+ max-width: 900px;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: $spacing-6;
+
+ @media (max-width: $breakpoint-md) {
+ grid-template-columns: 1fr;
+ max-width: 500px;
+ }
+}
+
+.verification-card {
+ background: white;
+ border-radius: $border-radius-2xl;
+ padding: $spacing-8;
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
+
+ [data-theme="dark"] & {
+ background: rgba(30, 30, 30, 0.95);
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
+ }
+}
+
+.verification-header {
+ text-align: center;
+ margin-bottom: $spacing-6;
+}
+
+.verification-icon {
+ font-size: 4rem;
+ margin-bottom: $spacing-3;
+ animation: bounce 2s ease-in-out infinite;
+}
+
+.verification-header h1 {
+ font-size: $font-size-3xl;
+ font-weight: $font-weight-bold;
+ color: var(--text-primary);
+ margin-bottom: $spacing-2;
+}
+
+.verification-subtitle {
+ font-size: $font-size-base;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.email-display {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: $spacing-2;
+ background: rgba(102, 126, 234, 0.1);
+ padding: $spacing-3 $spacing-4;
+ border-radius: $border-radius-lg;
+ margin-bottom: $spacing-5;
+ border: 2px solid rgba(102, 126, 234, 0.2);
+
+ [data-theme="dark"] & {
+ background: rgba(102, 126, 234, 0.15);
+ border-color: rgba(102, 126, 234, 0.3);
+ }
+}
+
+.email-icon {
+ font-size: $font-size-xl;
+}
+
+.email-text {
+ font-size: $font-size-lg;
+ font-weight: $font-weight-semibold;
+ color: #667eea;
+
+ [data-theme="dark"] & {
+ color: #8b9dfa;
+ }
+}
+
+.verification-form {
+ margin-bottom: $spacing-6;
+}
+
+.code-input {
+ text-align: center;
+ font-size: $font-size-3xl;
+ font-weight: $font-weight-bold;
+ letter-spacing: 0.5em;
+ font-family: 'JetBrains Mono', monospace;
+ padding: $spacing-4;
+
+ &:focus {
+ letter-spacing: 0.5em;
+ }
+}
+
+.verification-footer {
+ text-align: center;
+}
+
+.expiration-notice {
+ font-size: $font-size-sm;
+ color: $warning-color;
+ font-weight: $font-weight-semibold;
+ margin-bottom: $spacing-4;
+ padding: $spacing-2 $spacing-3;
+ background: rgba(245, 158, 11, 0.1);
+ border-radius: $border-radius-md;
+ display: inline-block;
+
+ [data-theme="dark"] & {
+ background: rgba(245, 158, 11, 0.15);
+ }
+}
+
+.resend-section {
+ margin-top: $spacing-4;
+ padding-top: $spacing-4;
+ border-top: 1px solid var(--border-color);
+
+ p {
+ font-size: $font-size-sm;
+ color: var(--text-secondary);
+ margin-bottom: $spacing-2;
+ }
+}
+
+.help-card {
+ background: white;
+ border-radius: $border-radius-2xl;
+ padding: $spacing-6;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
+
+ [data-theme="dark"] & {
+ background: rgba(30, 30, 30, 0.95);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
+ }
+
+ @media (max-width: $breakpoint-md) {
+ display: none;
+ }
+
+ h3 {
+ font-size: $font-size-lg;
+ font-weight: $font-weight-bold;
+ color: var(--text-primary);
+ margin-bottom: $spacing-4;
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+
+ li {
+ padding: $spacing-2 0;
+ font-size: $font-size-sm;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: flex-start;
+ gap: $spacing-2;
+
+ &::before {
+ content: '✓';
+ color: $success-color;
+ font-weight: $font-weight-bold;
+ flex-shrink: 0;
+ }
+ }
+ }
+}
+
+@keyframes bounce {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+}
From 29beb9d95614dc25a147a8ac233e85b1c5c01b8a Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:09:02 +0000
Subject: [PATCH 09/45] feat: se introduce funcionalidad nueva.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
controllers: el scope es correcto (package controllers).
Refleja claramente:
Autenticación de administrador
Dashboard con paginación y búsqueda
Estadísticas básicas y avanzadas
CRUD de contactos
Moderación de publicaciones
Endpoints JSON para dashboard profesional
---
app/controllers/AdminController.scala | 427 ++++++++++++++------------
1 file changed, 237 insertions(+), 190 deletions(-)
diff --git a/app/controllers/AdminController.scala b/app/controllers/AdminController.scala
index c33d66f..f603336 100644
--- a/app/controllers/AdminController.scala
+++ b/app/controllers/AdminController.scala
@@ -6,24 +6,27 @@ import play.api.data._
import play.api.data.Forms._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}
-import repositories.{ContactRepository, AdminRepository, UserRepository}
+import repositories.{ContactRepository, AdminRepository, UserRepository, PublicationRepository}
import models.{ContactRecord, Admin}
+import actions.{AdminOnlyAction, AuthRequest}
import org.mindrot.jbcrypt.BCrypt
import java.time.Instant
case class LoginForm(username: String, password: String)
case class ContactForm(name: String, email: String, message: String)
case class ContactUpdateForm(id: Long, name: String, email: String, message: String, status: String)
+case class PublicationReviewForm(publicationId: Long, action: String, rejectionReason: Option[String])
@Singleton
class AdminController @Inject()(
cc: ControllerComponents,
contactRepository: ContactRepository,
adminRepository: AdminRepository,
- userRepository: UserRepository
+ userRepository: UserRepository,
+ publicationRepository: PublicationRepository,
+ adminAction: AdminOnlyAction
)(implicit ec: ExecutionContext) extends AbstractController(cc) {
- // Definición de formularios
val loginForm = Form(
mapping(
"username" -> nonEmptyText,
@@ -49,37 +52,23 @@ class AdminController @Inject()(
)(ContactUpdateForm.apply)(ContactUpdateForm.unapply)
)
- // Helper para verificar autenticación
- private def isAuthenticated(request: RequestHeader): Boolean = {
- request.session.get("adminId").isDefined
- }
-
- private def withAuth(block: => Future[Result])(implicit request: RequestHeader): Future[Result] = {
- if (isAuthenticated(request)) {
- block
- } else {
- Future.successful(Redirect(routes.AdminController.loginPage()).withNewSession)
- }
- }
-
/**
- * Página de login
+ * Página de login - Redirige al login unificado
*/
def loginPage(): Action[AnyContent] = Action { implicit request =>
- // Si ya está logueado, redirigir al dashboard
- request.session.get("adminId") match {
- case Some(_) => Redirect(routes.AdminController.dashboard(0, None))
- case None => Ok(views.html.admin.login(loginForm))
- }
+ Redirect(routes.AuthController.loginPage())
}
/**
- * Procesar login
+ * Procesar login de admin
*/
def login(): Action[AnyContent] = Action.async { implicit request =>
loginForm.bindFromRequest().fold(
formWithErrors => {
- Future.successful(BadRequest(views.html.admin.login(formWithErrors)))
+ Future.successful(
+ Redirect(routes.AuthController.loginPage())
+ .flashing("error" -> "Error en el formulario. Por favor verifica los datos.")
+ )
},
loginData => {
adminRepository.findByUsername(loginData.username).flatMap {
@@ -87,12 +76,17 @@ class AdminController @Inject()(
// Actualizar último login
adminRepository.updateLastLogin(admin.id.get).map { _ =>
Redirect(routes.AdminController.dashboard(0, None))
- .withSession("adminId" -> admin.id.get.toString, "adminUsername" -> admin.username)
+ .withSession(
+ "userId" -> admin.id.get.toString,
+ "username" -> admin.username,
+ "userRole" -> "admin"
+ )
.flashing("success" -> s"Bienvenido, ${admin.username}")
}
case _ =>
Future.successful(
- Unauthorized(views.html.admin.login(loginForm.withGlobalError("Credenciales inválidas")))
+ Redirect(routes.AuthController.loginPage())
+ .flashing("error" -> "Credenciales de administrador inválidas")
)
}
}
@@ -103,167 +97,147 @@ class AdminController @Inject()(
* Logout
*/
def logout(): Action[AnyContent] = Action { implicit request =>
- Redirect(routes.AdminController.loginPage()).withNewSession.flashing("success" -> "Sesión cerrada")
+ Redirect(routes.AuthController.loginPage()).withNewSession.flashing("success" -> "Sesión cerrada")
}
/**
* Dashboard principal
*/
- def dashboard(page: Int, search: Option[String]): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- val username = request.session.get("adminUsername").getOrElse("Admin")
-
- for {
- contacts <- contactRepository.listAll()
- totalCount <- contactRepository.count()
- } yield {
- val filteredContacts = search match {
- case Some(query) if query.nonEmpty =>
- contacts.filter(c =>
- c.name.toLowerCase.contains(query.toLowerCase) ||
- c.email.toLowerCase.contains(query.toLowerCase) ||
- c.message.toLowerCase.contains(query.toLowerCase)
- )
- case _ => contacts
- }
-
- val pageSize = 10
- val offset = page * pageSize
- val paginatedContacts = filteredContacts.slice(offset, offset + pageSize)
- val totalPages = Math.ceil(filteredContacts.length.toDouble / pageSize).toInt
-
- Ok(views.html.admin.dashboard(paginatedContacts, username, page, totalPages, search))
+ def dashboard(page: Int, search: Option[String]): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ for {
+ contacts <- contactRepository.listAll()
+ totalCount <- contactRepository.count()
+ } yield {
+ val filteredContacts = search match {
+ case Some(query) if query.nonEmpty =>
+ contacts.filter(c =>
+ c.name.toLowerCase.contains(query.toLowerCase) ||
+ c.email.toLowerCase.contains(query.toLowerCase) ||
+ c.message.toLowerCase.contains(query.toLowerCase)
+ )
+ case _ => contacts
}
+
+ val pageSize = 10
+ val offset = page * pageSize
+ val paginatedContacts = filteredContacts.slice(offset, offset + pageSize)
+ val totalPages = Math.ceil(filteredContacts.length.toDouble / pageSize).toInt
+
+ Ok(views.html.admin.dashboard(paginatedContacts, request.username, page, totalPages, search))
}
}
/**
* Vista de estadísticas avanzadas
*/
- def statisticsPage(): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- val username = request.session.get("adminUsername").getOrElse("Admin")
- Future.successful(Ok(views.html.admin.statistics(username)))
- }
+ def statisticsPage(): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ val username = request.username
+ Future.successful(Ok(views.html.admin.statistics(username)))
}
/**
* Ver detalle de un contacto
*/
- def viewContact(id: Long): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- contactRepository.findById(id).map {
- case Some(contact) => Ok(views.html.admin.contactDetail(contact))
- case None => NotFound("Contacto no encontrado")
- }
+ def viewContact(id: Long): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ contactRepository.findById(id).map {
+ case Some(contact) => Ok(views.html.admin.contactDetail(contact))
+ case None => NotFound("Contacto no encontrado")
}
}
/**
* Página para crear nuevo contacto
*/
- def createContactPage(): Action[AnyContent] = Action { implicit request =>
- if (isAuthenticated(request)) {
- Ok(views.html.admin.contactForm(contactForm, None))
- } else {
- Redirect(routes.AdminController.loginPage()).withNewSession
- }
+ def createContactPage(): Action[AnyContent] = adminAction { implicit request: AuthRequest[AnyContent] =>
+ Ok(views.html.admin.contactForm(contactForm, None))
}
/**
* Crear nuevo contacto
*/
- def createContact(): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- contactForm.bindFromRequest().fold(
- formWithErrors => {
- Future.successful(BadRequest(views.html.admin.contactForm(formWithErrors, None)))
- },
- contactData => {
- val newContact = ContactRecord(
- id = None,
- name = contactData.name,
- email = contactData.email,
- message = contactData.message,
- createdAt = Instant.now(),
- status = "pending"
- )
- contactRepository.save(newContact).map { _ =>
- Redirect(routes.AdminController.dashboard(0, None))
- .flashing("success" -> "Contacto creado exitosamente")
- }
+ def createContact(): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ contactForm.bindFromRequest().fold(
+ formWithErrors => {
+ Future.successful(BadRequest(views.html.admin.contactForm(formWithErrors, None)))
+ },
+ contactData => {
+ val newContact = ContactRecord(
+ id = None,
+ name = contactData.name,
+ email = contactData.email,
+ message = contactData.message,
+ createdAt = Instant.now(),
+ status = "pending"
+ )
+ contactRepository.save(newContact).map { _ =>
+ Redirect(routes.AdminController.dashboard(0, None))
+ .flashing("success" -> "Contacto creado exitosamente")
}
- )
- }
+ }
+ )
}
/**
* Página para editar contacto
*/
- def editContactPage(id: Long): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- contactRepository.findById(id).map {
- case Some(contact) =>
- val filledForm = contactUpdateForm.fill(ContactUpdateForm(
- contact.id.get,
- contact.name,
- contact.email,
- contact.message,
- contact.status
- ))
- Ok(views.html.admin.contactEdit(filledForm, contact))
- case None => NotFound("Contacto no encontrado")
- }
+ def editContactPage(id: Long): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ contactRepository.findById(id).map {
+ case Some(contact) =>
+ val filledForm = contactUpdateForm.fill(ContactUpdateForm(
+ contact.id.get,
+ contact.name,
+ contact.email,
+ contact.message,
+ contact.status
+ ))
+ Ok(views.html.admin.contactEdit(filledForm, contact))
+ case None => NotFound("Contacto no encontrado")
}
}
/**
* Actualizar contacto
*/
- def updateContact(id: Long): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- contactUpdateForm.bindFromRequest().fold(
- formWithErrors => {
- contactRepository.findById(id).map {
- case Some(contact) => BadRequest(views.html.admin.contactEdit(formWithErrors, contact))
- case None => NotFound("Contacto no encontrado")
- }
- },
- updateData => {
- val updatedContact = ContactRecord(
- id = Some(id),
- name = updateData.name,
- email = updateData.email,
- message = updateData.message,
- createdAt = Instant.now(),
- status = updateData.status
- )
-
- contactRepository.update(id, updatedContact).map { count =>
- if (count > 0) {
- Redirect(routes.AdminController.dashboard(0, None))
- .flashing("success" -> "Contacto actualizado exitosamente")
- } else {
- NotFound("Contacto no encontrado")
- }
+ def updateContact(id: Long): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ contactUpdateForm.bindFromRequest().fold(
+ formWithErrors => {
+ contactRepository.findById(id).map {
+ case Some(contact) => BadRequest(views.html.admin.contactEdit(formWithErrors, contact))
+ case None => NotFound("Contacto no encontrado")
+ }
+ },
+ updateData => {
+ val updatedContact = ContactRecord(
+ id = Some(id),
+ name = updateData.name,
+ email = updateData.email,
+ message = updateData.message,
+ createdAt = Instant.now(),
+ status = updateData.status
+ )
+
+ contactRepository.update(id, updatedContact).map { count =>
+ if (count > 0) {
+ Redirect(routes.AdminController.dashboard(0, None))
+ .flashing("success" -> "Contacto actualizado exitosamente")
+ } else {
+ NotFound("Contacto no encontrado")
}
}
- )
- }
+ }
+ )
}
/**
* Eliminar contacto
*/
- def deleteContact(id: Long): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- contactRepository.delete(id).map { count =>
- if (count > 0) {
- Redirect(routes.AdminController.dashboard(0, None))
- .flashing("success" -> "Contacto eliminado exitosamente")
- } else {
- NotFound("Contacto no encontrado")
- }
+ def deleteContact(id: Long): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ contactRepository.delete(id).map { count =>
+ if (count > 0) {
+ Redirect(routes.AdminController.dashboard(0, None))
+ .flashing("success" -> "Contacto eliminado exitosamente")
+ } else {
+ NotFound("Contacto no encontrado")
}
}
}
@@ -271,14 +245,12 @@ class AdminController @Inject()(
/**
* API JSON para actualizar estado rápidamente
*/
- def updateStatus(id: Long, status: String): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- contactRepository.updateStatus(id, status).map { count =>
- if (count > 0) {
- Ok(Json.obj("success" -> true, "message" -> "Estado actualizado"))
- } else {
- NotFound(Json.obj("success" -> false, "message" -> "Contacto no encontrado"))
- }
+ def updateStatus(id: Long, status: String): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ contactRepository.updateStatus(id, status).map { count =>
+ if (count > 0) {
+ Ok(Json.obj("success" -> true, "message" -> "Estado actualizado"))
+ } else {
+ NotFound(Json.obj("success" -> false, "message" -> "Contacto no encontrado"))
}
}
}
@@ -286,53 +258,50 @@ class AdminController @Inject()(
/**
* Estadísticas del dashboard
*/
- def stats(): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- for {
- totalCount <- contactRepository.count()
- allContacts <- contactRepository.listAll()
- } yield {
- val pendingCount = allContacts.count(_.status == "pending")
- val processedCount = allContacts.count(_.status == "processed")
- val archivedCount = allContacts.count(_.status == "archived")
-
- Ok(Json.obj(
- "total" -> totalCount,
- "pending" -> pendingCount,
- "processed" -> processedCount,
- "archived" -> archivedCount
- ))
- }
+ def stats(): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ for {
+ totalCount <- contactRepository.count()
+ allContacts <- contactRepository.listAll()
+ } yield {
+ val pendingCount = allContacts.count(_.status == "pending")
+ val processedCount = allContacts.count(_.status == "processed")
+ val archivedCount = allContacts.count(_.status == "archived")
+
+ Ok(Json.obj(
+ "total" -> totalCount,
+ "pending" -> pendingCount,
+ "processed" -> processedCount,
+ "archived" -> archivedCount
+ ))
}
}
/**
* Estadísticas avanzadas para el dashboard profesional
*/
- def advancedStats(): Action[AnyContent] = Action.async { implicit request =>
- withAuth {
- for {
- // Estadísticas de usuarios
- totalUsers <- userRepository.count()
- allUsers <- userRepository.listAll()
- usersByRole <- userRepository.countByRole()
- usersLast7Days <- userRepository.getUsersRegisteredInLastDays(7)
- usersLast30Days <- userRepository.getUsersRegisteredInLastDays(30)
- activeUsersLast7Days <- userRepository.getActiveUsersInLastDays(7)
- neverLoggedIn <- userRepository.countNeverLoggedIn()
-
- // Estadísticas de contactos
- totalContacts <- contactRepository.count()
- allContacts <- contactRepository.listAll()
- contactsByStatus <- contactRepository.countByStatus()
- contactsLast7Days <- contactRepository.getContactsInLastDays(7)
- contactsLast30Days <- contactRepository.getContactsInLastDays(30)
-
- // Estadísticas de administradores
- totalAdmins <- adminRepository.count()
- allAdmins <- adminRepository.listAll()
-
- } yield {
+ def advancedStats(): Action[AnyContent] = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ for {
+ // Estadísticas de usuarios
+ totalUsers <- userRepository.count()
+ allUsers <- userRepository.listAll()
+ usersByRole <- userRepository.countByRole()
+ usersLast7Days <- userRepository.getUsersRegisteredInLastDays(7)
+ usersLast30Days <- userRepository.getUsersRegisteredInLastDays(30)
+ activeUsersLast7Days <- userRepository.getActiveUsersInLastDays(7)
+ neverLoggedIn <- userRepository.countNeverLoggedIn()
+
+ // Estadísticas de contactos
+ totalContacts <- contactRepository.count()
+ allContacts <- contactRepository.listAll()
+ contactsByStatus <- contactRepository.countByStatus()
+ contactsLast7Days <- contactRepository.getContactsInLastDays(7)
+ contactsLast30Days <- contactRepository.getContactsInLastDays(30)
+
+ // Estadísticas de administradores
+ totalAdmins <- adminRepository.count()
+ allAdmins <- adminRepository.listAll()
+
+ } yield {
// Calcular métricas de tiempo promedio
val now = Instant.now()
val avgUserAge = if (allUsers.nonEmpty) {
@@ -411,6 +380,84 @@ class AdminController @Inject()(
)
))
}
+ }
+
+ // ============================================
+ // GESTIÓN DE PUBLICACIONES
+ // ============================================
+
+ /**
+ * Ver publicaciones pendientes de aprobación
+ */
+ def pendingPublications = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepository.findPending().map { publications =>
+ Ok(views.html.admin.publicationReview(publications))
+ }
+ }
+
+ /**
+ * Ver detalle de una publicación para revisión
+ */
+ def reviewPublicationDetail(id: Long) = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepository.findById(id).map {
+ case Some(publication) =>
+ Ok(views.html.admin.publicationDetail(publication))
+ case None =>
+ NotFound("Publicación no encontrada")
+ }
+ }
+
+ /**
+ * Aprobar una publicación
+ */
+ def approvePublication(id: Long) = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ val adminId = request.userId
+ publicationRepository.changeStatus(id, "approved", adminId).map { success =>
+ if (success) {
+ Redirect(routes.AdminController.pendingPublications())
+ .flashing("success" -> "Publicación aprobada exitosamente")
+ } else {
+ BadRequest("Error al aprobar la publicación")
+ }
+ }
+ }
+
+ /**
+ * Rechazar una publicación
+ */
+ def rejectPublication(id: Long) = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ val rejectionReason = request.body.asFormUrlEncoded
+ .flatMap(_.get("rejectionReason"))
+ .flatMap(_.headOption)
+ .getOrElse("No cumple con los estándares de calidad")
+
+ val adminId = request.userId
+ publicationRepository.changeStatus(id, "rejected", adminId, Some(rejectionReason)).map { success =>
+ if (success) {
+ Redirect(routes.AdminController.pendingPublications())
+ .flashing("success" -> "Publicación rechazada")
+ } else {
+ BadRequest("Error al rechazar la publicación")
+ }
+ }
+ }
+
+ /**
+ * API: Listar todas las publicaciones (para admin)
+ */
+ def listAllPublicationsJson = adminAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepository.findPending().map { publications =>
+ Ok(Json.toJson(publications.map { pubWithAuthor =>
+ Json.obj(
+ "id" -> pubWithAuthor.publication.id,
+ "title" -> pubWithAuthor.publication.title,
+ "author" -> pubWithAuthor.authorUsername,
+ "status" -> pubWithAuthor.publication.status,
+ "category" -> pubWithAuthor.publication.category,
+ "createdAt" -> pubWithAuthor.publication.createdAt.toString,
+ "updatedAt" -> pubWithAuthor.publication.updatedAt.toString
+ )
+ }))
}
}
-}
+}
\ No newline at end of file
From cc6e8b5cb47beabb227e28f62b0903dfc6b0ae70 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:16:59 +0000
Subject: [PATCH 10/45] chore(build): configurar proyecto Play con dependencias
base, Akka, Slick y seguridad de dependencias
---
build.sbt | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/build.sbt b/build.sbt
index 43a74f6..0d1e67d 100644
--- a/build.sbt
+++ b/build.sbt
@@ -11,6 +11,7 @@ scalaVersion := "2.13.12"
libraryDependencies ++= Seq(
guice,
+ jdbc, // Play JDBC API
// Reactive Manifesto - Core message-driven
"com.typesafe.akka" %% "akka-actor-typed" % "2.8.5",
@@ -25,6 +26,9 @@ libraryDependencies ++= Seq(
// BCrypt for password hashing
"org.mindrot" % "jbcrypt" % "0.4",
+ // Email sending
+ "com.sun.mail" % "javax.mail" % "1.6.2",
+
// Testing
"org.scalatestplus.play" %% "scalatestplus-play" % "7.0.0" % Test
)
From 3006ef2727ab7536cc9968d705efe7e529d109c7 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:22:54 +0000
Subject: [PATCH 11/45] Recursos documentados
---
resource/COMANDOS_EMAIL.md | 401 ++++++++++++++++++++++++
resource/CONFIGURAR_GMAIL.md | 139 ++++++++
resource/EMAIL_CONFIGURATION.md | 184 +++++++++++
resource/EMAIL_VERIFICATION_SUMMARY.md | 266 ++++++++++++++++
resource/FLUJO_VERIFICACION_EMAIL.md | 198 ++++++++++++
resource/GUIA_CREAR_PUBLICACION.md | 240 ++++++++++++++
resource/PROBLEMA_EMAIL_VERIFICACION.md | 188 +++++++++++
resource/PUBLICATIONS_SYSTEM.md | 360 +++++++++++++++++++++
resource/email-preview.html | 197 ++++++++++++
9 files changed, 2173 insertions(+)
create mode 100644 resource/COMANDOS_EMAIL.md
create mode 100644 resource/CONFIGURAR_GMAIL.md
create mode 100644 resource/EMAIL_CONFIGURATION.md
create mode 100644 resource/EMAIL_VERIFICATION_SUMMARY.md
create mode 100644 resource/FLUJO_VERIFICACION_EMAIL.md
create mode 100644 resource/GUIA_CREAR_PUBLICACION.md
create mode 100644 resource/PROBLEMA_EMAIL_VERIFICACION.md
create mode 100644 resource/PUBLICATIONS_SYSTEM.md
create mode 100644 resource/email-preview.html
diff --git a/resource/COMANDOS_EMAIL.md b/resource/COMANDOS_EMAIL.md
new file mode 100644
index 0000000..c2b90ff
--- /dev/null
+++ b/resource/COMANDOS_EMAIL.md
@@ -0,0 +1,401 @@
+# 🚀 Comandos Rápidos - Sistema de Verificación de Email
+
+## 📝 Desarrollo Local
+
+### Iniciar Aplicación (Modo Desarrollo)
+```bash
+# Modo normal
+sbt run
+
+# Con script de verificación
+./test-email.sh
+```
+
+### Compilar
+```bash
+# Compilación normal
+sbt compile
+
+# Compilación limpia (desde cero)
+sbt clean compile
+
+# Compilar assets (CSS/SCSS)
+sbt assets
+```
+
+### Ver Logs en Tiempo Real
+```bash
+# Los códigos de verificación aparecen como:
+========================================
+ 📧 CÓDIGO DE VERIFICACIÓN (DEV MODE)
+========================================
+ Email: usuario@example.com
+ Código: 456
+ Expira en: 5 minutos
+========================================
+```
+
+---
+
+## 🌐 Producción
+
+### Activar Envío Real de Emails
+
+#### 1. Editar Configuración
+```bash
+nano conf/application.conf
+
+# Cambiar esta línea:
+email.enabled = false
+# Por esta:
+email.enabled = true
+```
+
+#### 2. Descomentar Configuración SMTP
+```conf
+email.smtp.host = "smtp.gmail.com"
+email.smtp.port = 587
+email.smtp.user = ${?EMAIL_USER}
+email.smtp.password = ${?EMAIL_PASSWORD}
+email.from = "tu-email@gmail.com"
+email.fromName = "Reactive Manifesto"
+```
+
+#### 3. Configurar Variables de Entorno
+```bash
+# Temporal (sesión actual)
+export EMAIL_USER="tu-email@gmail.com"
+export EMAIL_PASSWORD="xxxx-xxxx-xxxx-xxxx"
+
+# Permanente (agregar a ~/.bashrc o ~/.zshrc)
+echo 'export EMAIL_USER="tu-email@gmail.com"' >> ~/.bashrc
+echo 'export EMAIL_PASSWORD="xxxx-xxxx-xxxx-xxxx"' >> ~/.bashrc
+source ~/.bashrc
+```
+
+#### 4. Reiniciar Aplicación
+```bash
+sbt run
+```
+
+---
+
+## 🧪 Testing
+
+### Probar Flujo de Verificación
+
+1. **Iniciar aplicación**
+ ```bash
+ sbt run
+ ```
+
+2. **Abrir navegador**
+ ```
+ http://localhost:9000
+ ```
+
+3. **Registrar nuevo usuario**
+ - Click en "Registrarse"
+ - Completar formulario
+ - Submit
+
+4. **Ver código en consola** (modo desarrollo)
+ - Buscar en logs el código de 3 dígitos
+ - Ejemplo: `Código: 456`
+
+5. **Ingresar código**
+ - Serás redirigido automáticamente a `/verify-email/:userId`
+ - Ingresar el código de 3 dígitos
+ - Submit
+
+6. **Verificación exitosa**
+ - Redirige al dashboard
+ - Email marcado como verificado
+
+### Probar Escenarios de Error
+
+#### Código Incorrecto
+```
+1. Ingresar código erróneo (ej: 999)
+2. Ver mensaje de error
+3. Contador de intentos incrementa
+4. Después de 3 intentos, código se bloquea
+```
+
+#### Código Expirado
+```
+1. Esperar más de 5 minutos
+2. Intentar usar el código
+3. Ver mensaje "Código expirado"
+4. Click en "Reenviar código"
+5. Recibir nuevo código
+```
+
+#### Reenviar Código
+```
+1. En página de verificación
+2. Click en "Reenviar código"
+3. Código nuevo generado
+4. Código anterior invalidado
+```
+
+---
+
+## 🗄️ Base de Datos
+
+### Verificar Migraciones
+```bash
+# Las migraciones se aplican automáticamente al iniciar
+# Ver estado en:
+http://localhost:9000/@evolutions
+```
+
+### Consultas Útiles (H2 Console)
+
+```sql
+-- Ver usuarios no verificados
+SELECT id, full_name, email, email_verified
+FROM users
+WHERE email_verified = false;
+
+-- Ver códigos de verificación activos
+SELECT * FROM email_verification_codes
+WHERE verified = false
+ AND expires_at > CURRENT_TIMESTAMP;
+
+-- Ver intentos por código
+SELECT user_id, code, attempts, verified, expires_at
+FROM email_verification_codes
+ORDER BY created_at DESC;
+
+-- Marcar usuario como verificado manualmente (solo para testing)
+UPDATE users
+SET email_verified = true
+WHERE email = 'usuario@example.com';
+
+-- Limpiar códigos expirados
+DELETE FROM email_verification_codes
+WHERE expires_at < CURRENT_TIMESTAMP;
+```
+
+### Acceder a H2 Console
+```
+URL: http://localhost:9000/@db
+JDBC URL: jdbc:h2:mem:play
+User: sa
+Password: (vacío)
+```
+
+---
+
+## 📧 Gmail - Configuración Rápida
+
+### Generar Contraseña de Aplicación
+
+1. **Habilitar 2FA**
+ ```
+ https://myaccount.google.com/security
+ → Verificación en 2 pasos → Activar
+ ```
+
+2. **Generar Contraseña**
+ ```
+ https://myaccount.google.com/apppasswords
+ → Seleccionar "Correo"
+ → Seleccionar "Otro"
+ → Escribir "Reactive Manifesto"
+ → Generar
+ ```
+
+3. **Copiar Contraseña** (16 caracteres)
+ ```
+ xxxx xxxx xxxx xxxx
+ ```
+
+4. **Configurar Variables**
+ ```bash
+ export EMAIL_USER="tu-email@gmail.com"
+ export EMAIL_PASSWORD="xxxx xxxx xxxx xxxx"
+ ```
+
+---
+
+## 🐳 Docker
+
+### Con Docker Compose
+```yaml
+# docker-compose.yml
+services:
+ app:
+ build: .
+ ports:
+ - "9000:9000"
+ environment:
+ - EMAIL_USER=${EMAIL_USER}
+ - EMAIL_PASSWORD=${EMAIL_PASSWORD}
+```
+
+### Ejecutar
+```bash
+# Configurar variables
+export EMAIL_USER="tu-email@gmail.com"
+export EMAIL_PASSWORD="xxxx-xxxx-xxxx-xxxx"
+
+# Iniciar
+docker-compose up -d
+
+# Ver logs
+docker-compose logs -f
+```
+
+---
+
+## 🔍 Debugging
+
+### Ver Logs Detallados
+```bash
+# Iniciar con logs de debug
+sbt -Dlogger.root=DEBUG run
+```
+
+### Verificar Configuración Actual
+```bash
+# Ver si email está habilitado
+grep "email.enabled" conf/application.conf
+
+# Ver configuración SMTP
+grep "email.smtp" conf/application.conf
+```
+
+### Probar Conexión SMTP (sin SBT)
+```bash
+# Usando curl (si está instalado)
+curl -v --url 'smtp://smtp.gmail.com:587' \
+ --ssl-reqd \
+ --mail-from 'tu-email@gmail.com' \
+ --mail-rcpt 'destino@example.com' \
+ --upload-file email.txt \
+ --user 'tu-email@gmail.com:xxxx-xxxx-xxxx-xxxx'
+```
+
+---
+
+## 📊 Monitoreo
+
+### Logs Importantes a Buscar
+
+#### Éxito
+```
+✅ Email enviado exitosamente a usuario@example.com
+📧 Código 456 enviado a usuario@example.com
+```
+
+#### Errores
+```
+❌ Error enviando email a usuario@example.com: Authentication failed
+❌ Error enviando email a usuario@example.com: Connection timeout
+```
+
+### Estadísticas Rápidas
+```sql
+-- Tasa de verificación
+SELECT
+ COUNT(*) as total_usuarios,
+ SUM(CASE WHEN email_verified THEN 1 ELSE 0 END) as verificados,
+ ROUND(100.0 * SUM(CASE WHEN email_verified THEN 1 ELSE 0 END) / COUNT(*), 2) as porcentaje
+FROM users;
+
+-- Códigos por día
+SELECT
+ DATE(created_at) as fecha,
+ COUNT(*) as codigos_generados,
+ SUM(CASE WHEN verified THEN 1 ELSE 0 END) as verificados
+FROM email_verification_codes
+GROUP BY DATE(created_at)
+ORDER BY fecha DESC;
+```
+
+---
+
+## 🛠️ Mantenimiento
+
+### Limpiar Códigos Expirados
+```sql
+-- Manual
+DELETE FROM email_verification_codes
+WHERE expires_at < CURRENT_TIMESTAMP;
+
+-- O dejar que el servicio lo haga automáticamente
+-- (se ejecuta cada vez que se crea un nuevo código)
+```
+
+### Reset de Usuario (para testing)
+```sql
+-- Desverificar usuario
+UPDATE users
+SET email_verified = false
+WHERE email = 'usuario@example.com';
+
+-- Eliminar códigos anteriores
+DELETE FROM email_verification_codes
+WHERE user_id = (SELECT id FROM users WHERE email = 'usuario@example.com');
+```
+
+---
+
+## 📁 Archivos de Referencia Rápida
+
+| Archivo | Propósito |
+|---------|-----------|
+| `conf/application.conf` | Configuración SMTP |
+| `app/services/EmailService.scala` | Lógica de envío |
+| `app/services/EmailVerificationService.scala` | Lógica de verificación |
+| `resource/EMAIL_CONFIGURATION.md` | Guía completa |
+| `resource/email-preview.html` | Vista previa de emails |
+| `test-email.sh` | Script de inicio con verificación |
+
+---
+
+## 🆘 Solución de Problemas Comunes
+
+### "Authentication failed"
+```bash
+# Verificar que EMAIL_USER y EMAIL_PASSWORD estén configurados
+echo $EMAIL_USER
+echo $EMAIL_PASSWORD
+
+# Regenerar contraseña de aplicación en Gmail
+# https://myaccount.google.com/apppasswords
+```
+
+### "Connection timeout"
+```bash
+# Verificar firewall
+sudo ufw status
+
+# Probar conectividad
+telnet smtp.gmail.com 587
+
+# Intentar con puerto SSL (465) en lugar de TLS (587)
+```
+
+### Códigos no aparecen en logs
+```bash
+# Verificar que estés mirando los logs correctos
+# Los códigos aparecen en stdout cuando sbt run está activo
+
+# Aumentar nivel de log
+# En logback.xml cambiar a DEBUG
+```
+
+### Email no llega
+```bash
+# 1. Verificar SPAM
+# 2. Verificar límites diarios de Gmail (500/día)
+# 3. Ver logs de error en la consola
+# 4. Verificar email.enabled = true
+```
+
+---
+
+**Tip**: Mantén este archivo abierto en una terminal mientras desarrollas para acceso rápido a comandos comunes! 🚀
diff --git a/resource/CONFIGURAR_GMAIL.md b/resource/CONFIGURAR_GMAIL.md
new file mode 100644
index 0000000..f2efefb
--- /dev/null
+++ b/resource/CONFIGURAR_GMAIL.md
@@ -0,0 +1,139 @@
+# 🔐 Configurar Gmail para Envío de Emails
+
+## ⚠️ Importante: Contraseña de Aplicación
+
+Gmail **NO acepta tu contraseña normal** para aplicaciones externas por seguridad.
+Necesitas generar una **Contraseña de Aplicación** (App Password).
+
+---
+
+## 📝 Pasos para Configurar Gmail
+
+### 1️⃣ Habilitar Verificación en 2 Pasos
+
+1. Ve a: https://myaccount.google.com/security
+2. En la sección **"Cómo iniciar sesión en Google"**
+3. Haz clic en **"Verificación en 2 pasos"**
+4. Sigue los pasos para habilitarla (si aún no lo has hecho)
+
+### 2️⃣ Generar Contraseña de Aplicación
+
+1. Ve a: https://myaccount.google.com/apppasswords
+2. En **"Seleccionar app"**: elige **"Correo"**
+3. En **"Seleccionar dispositivo"**: elige **"Otro (nombre personalizado)"**
+4. Escribe: **"Reactive Manifesto"**
+5. Haz clic en **"Generar"**
+6. Gmail mostrará una contraseña de 16 caracteres: `xxxx xxxx xxxx xxxx`
+7. **¡Cópiala! No podrás verla de nuevo**
+
+### 3️⃣ Configurar Variables de Entorno
+
+```bash
+# Exportar las variables (reemplaza con tu contraseña de aplicación)
+export EMAIL_USER="federicopfund@gmail.com"
+export EMAIL_PASSWORD="xxxx xxxx xxxx xxxx" # Los 16 caracteres generados por Gmail
+
+# Verificar que se configuraron correctamente
+echo "Usuario: $EMAIL_USER"
+echo "Contraseña configurada: ${EMAIL_PASSWORD:0:4}****" # Solo muestra primeros 4 caracteres
+```
+
+### 4️⃣ Reiniciar la Aplicación
+
+```bash
+cd /workspaces/Reactive-Manifiesto
+sbt run
+```
+
+---
+
+## ✅ Verificar Funcionamiento
+
+1. **Inicia la aplicación**: `sbt run`
+2. **Abre el navegador**: http://localhost:9000
+3. **Registra un usuario** con tu email
+4. **Revisa tu bandeja de entrada** (o spam)
+5. **Copia el código de 3 dígitos** del email
+6. **Ingrésalo** en la página de verificación
+
+---
+
+## 🐛 Solución de Problemas
+
+### Error: "Username and Password not accepted"
+
+✅ **Causa**: Estás usando tu contraseña normal de Gmail
+📝 **Solución**: Usa la contraseña de aplicación generada en el Paso 2
+
+### Error: "Connection timeout"
+
+✅ **Causa**: Firewall o puerto bloqueado
+📝 **Solución**:
+```bash
+# Verificar conectividad
+telnet smtp.gmail.com 587
+
+# Si falla, intenta con puerto 465 (SSL):
+# Edita application.conf y cambia:
+# email.smtp.port = 465
+```
+
+### Los emails no llegan
+
+✅ **Revisa SPAM**: Gmail puede marcarlos como spam la primera vez
+✅ **Verifica límites**: Gmail gratuito permite ~500 emails/día
+✅ **Chequea logs**: Busca errores en la consola de sbt
+
+---
+
+## 📊 Límites de Gmail
+
+| Tipo de Cuenta | Límite Diario |
+|----------------|---------------|
+| Gmail Gratuito | ~500 emails |
+| Google Workspace | ~2000 emails |
+
+Para volúmenes mayores, considera:
+- SendGrid (12,000 gratis/mes)
+- Mailgun (5,000 gratis/mes)
+- AWS SES (62,000 gratis/mes)
+
+---
+
+## 🔒 Seguridad
+
+✅ **Nunca compartas** tu contraseña de aplicación
+✅ **Nunca hagas commit** de credenciales en Git
+✅ **Usa variables de entorno** siempre
+✅ **Revoca contraseñas** no utilizadas en: https://myaccount.google.com/apppasswords
+
+---
+
+## 📧 Formato del Email que Recibirán los Usuarios
+
+```
+De: Reactive Manifesto
+Para: usuario@example.com
+Asunto: Código de Verificación - Reactive Manifesto
+
+╔═══════════════════════════════════╗
+║ 🔐 Código de Verificación ║
+╚═══════════════════════════════════╝
+
+Hola,
+
+Usa el siguiente código para verificar tu cuenta:
+
+ ╔═══════════╗
+ ║ 4 5 6 ║
+ ╚═══════════╝
+
+⏱️ Este código expira en 5 minutos
+👥 Tienes máximo 3 intentos
+
+Si no solicitaste este código, ignora este email.
+```
+
+---
+
+**¿Listo?** Sigue los pasos arriba y tu aplicación enviará emails automáticamente! 🚀
diff --git a/resource/EMAIL_CONFIGURATION.md b/resource/EMAIL_CONFIGURATION.md
new file mode 100644
index 0000000..4aaf5d3
--- /dev/null
+++ b/resource/EMAIL_CONFIGURATION.md
@@ -0,0 +1,184 @@
+# Configuración de Email
+
+## 📧 Sistema de Envío de Emails
+
+El sistema de verificación por email está completamente implementado y soporta dos modos de operación:
+
+### Modo Desarrollo (Por Defecto)
+- **Estado**: `email.enabled = false`
+- **Comportamiento**: Los códigos de verificación se muestran en la consola/logs
+- **Uso**: Ideal para desarrollo y testing sin necesidad de configurar SMTP
+
+### Modo Producción
+- **Estado**: `email.enabled = true`
+- **Comportamiento**: Los emails se envían realmente a las direcciones de los usuarios
+- **Requisito**: Configuración SMTP válida
+
+## 🔧 Configuración para Producción
+
+### Opción 1: Gmail (Recomendado para empezar)
+
+#### Paso 1: Habilitar Verificación en 2 Pasos
+1. Ve a [Google Account Security](https://myaccount.google.com/security)
+2. Habilita "Verificación en 2 pasos"
+
+#### Paso 2: Generar Contraseña de Aplicación
+1. Ve a [App Passwords](https://myaccount.google.com/apppasswords)
+2. Selecciona "Correo" y "Otro (nombre personalizado)"
+3. Escribe "Reactive Manifesto" y genera
+4. Copia la contraseña generada (16 caracteres)
+
+#### Paso 3: Configurar Variables de Entorno
+```bash
+export EMAIL_USER="tu-email@gmail.com"
+export EMAIL_PASSWORD="xxxx xxxx xxxx xxxx" # Contraseña de aplicación generada
+```
+
+#### Paso 4: Actualizar application.conf
+```conf
+email.enabled = true
+email.smtp.host = "smtp.gmail.com"
+email.smtp.port = 587
+email.smtp.user = ${?EMAIL_USER}
+email.smtp.password = ${?EMAIL_PASSWORD}
+email.from = "tu-email@gmail.com"
+email.fromName = "Reactive Manifesto"
+```
+
+### Opción 2: Otros Proveedores SMTP
+
+#### SendGrid
+```conf
+email.smtp.host = "smtp.sendgrid.net"
+email.smtp.port = 587
+email.smtp.user = "apikey"
+email.smtp.password = ${?SENDGRID_API_KEY}
+```
+
+#### Mailgun
+```conf
+email.smtp.host = "smtp.mailgun.org"
+email.smtp.port = 587
+email.smtp.user = ${?MAILGUN_USER}
+email.smtp.password = ${?MAILGUN_PASSWORD}
+```
+
+#### Amazon SES
+```conf
+email.smtp.host = "email-smtp.us-east-1.amazonaws.com"
+email.smtp.port = 587
+email.smtp.user = ${?AWS_SMTP_USER}
+email.smtp.password = ${?AWS_SMTP_PASSWORD}
+```
+
+## 📝 Templates de Email
+
+### Código de Verificación
+El sistema envía un email HTML con:
+- 🔐 Título "Código de Verificación"
+- Código numérico de 3 dígitos en formato grande y claro
+- ⏱️ Tiempo de expiración (5 minutos)
+- Advertencia sobre límite de intentos (máximo 3)
+- Diseño profesional con gradiente púrpura
+
+### Email de Bienvenida
+Enviado después de verificar la cuenta con:
+- 👋 Saludo personalizado con el nombre del usuario
+- Lista de características disponibles
+- Diseño consistente con el tema de verificación
+
+## 🧪 Testing
+
+### Probar en Modo Desarrollo
+1. Mantén `email.enabled = false`
+2. Inicia sesión con un usuario no verificado
+3. El código aparecerá en los logs:
+```
+========================================
+ 📧 CÓDIGO DE VERIFICACIÓN (DEV MODE)
+========================================
+ Email: usuario@example.com
+ Código: 456
+ Expira en: 5 minutos
+========================================
+```
+
+### Probar en Modo Producción
+1. Configura SMTP según las instrucciones arriba
+2. Cambia `email.enabled = true`
+3. Reinicia la aplicación
+4. Inicia sesión con un usuario no verificado
+5. Verifica que el email llegue a la bandeja de entrada
+
+## 🔒 Seguridad
+
+### Mejores Prácticas
+- ✅ **Nunca** commits credenciales SMTP en el código
+- ✅ Usa variables de entorno para información sensible
+- ✅ Usa contraseñas de aplicación, no tu contraseña personal de Gmail
+- ✅ Configura SPF/DKIM/DMARC si usas tu propio dominio
+- ✅ Monitorea límites de envío de tu proveedor
+
+### Límites de Gmail
+- **Gratuito**: ~500 emails por día
+- **Google Workspace**: ~2000 emails por día
+- Para volúmenes mayores, considera SendGrid, Mailgun o AWS SES
+
+## 🚀 Deployment
+
+### Variables de Entorno Requeridas (Producción)
+```bash
+EMAIL_USER=tu-email@gmail.com
+EMAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx
+```
+
+### Docker
+Agrega al `docker-compose.yml`:
+```yaml
+environment:
+ - EMAIL_USER=${EMAIL_USER}
+ - EMAIL_PASSWORD=${EMAIL_PASSWORD}
+```
+
+### Render/Heroku
+Configura las variables en el dashboard:
+- `EMAIL_USER`
+- `EMAIL_PASSWORD`
+
+## 📊 Monitoreo
+
+### Logs Importantes
+```scala
+✅ Email enviado exitosamente a usuario@example.com
+❌ Error enviando email a usuario@example.com: Authentication failed
+```
+
+### Troubleshooting
+
+#### Error: "Authentication failed"
+- Verifica que la contraseña de aplicación sea correcta
+- Confirma que la verificación en 2 pasos esté habilitada
+
+#### Error: "Connection timeout"
+- Verifica tu firewall/red permite conexiones al puerto 587
+- Prueba con puerto 465 (SSL) si 587 falla
+
+#### Emails llegan a SPAM
+- Configura SPF record en tu dominio
+- Usa un email del mismo dominio que tu aplicación
+- Evita palabras spam en el asunto
+
+## 🎨 Personalización
+
+Para personalizar los templates de email, edita los métodos en `EmailService.scala`:
+- `createVerificationEmailHtml(code, expirationMinutes)`
+- `createWelcomeEmailHtml(fullName)`
+
+Los templates usan HTML inline CSS para máxima compatibilidad con clientes de email.
+
+## 📚 Referencias
+
+- [JavaMail API Documentation](https://javaee.github.io/javamail/)
+- [Gmail App Passwords](https://support.google.com/accounts/answer/185833)
+- [SendGrid Documentation](https://docs.sendgrid.com/)
+- [Amazon SES Documentation](https://docs.aws.amazon.com/ses/)
diff --git a/resource/EMAIL_VERIFICATION_SUMMARY.md b/resource/EMAIL_VERIFICATION_SUMMARY.md
new file mode 100644
index 0000000..e215d28
--- /dev/null
+++ b/resource/EMAIL_VERIFICATION_SUMMARY.md
@@ -0,0 +1,266 @@
+# 📧 Sistema de Verificación de Email - Resumen Ejecutivo
+
+## ✅ Implementación Completada
+
+### 🎯 Objetivo Logrado
+Se ha implementado exitosamente un **sistema completo de verificación de email** que envía códigos de 3 dígitos a los usuarios para verificar sus cuentas antes de permitirles acceder a la aplicación.
+
+---
+
+## 🚀 Características Implementadas
+
+### 1. **Base de Datos**
+- ✅ Nueva tabla `email_verification_codes` con:
+ - Código de 3 dígitos
+ - Fecha de expiración (5 minutos)
+ - Contador de intentos (máximo 3)
+ - Estado de verificación
+- ✅ Campo `email_verified` agregado a la tabla `users`
+- ✅ Migraciones automáticas con Play Evolutions
+
+### 2. **Backend (Scala/Play Framework)**
+- ✅ `EmailVerificationCode` - Modelo de dominio con validaciones
+- ✅ `EmailVerificationRepository` - Repositorio con operaciones CRUD usando Slick
+- ✅ `EmailVerificationService` - Lógica de negocio:
+ - Generación de códigos aleatorios (100-999)
+ - Validación con límite de intentos
+ - Manejo de expiración
+ - Limpieza de códigos expirados
+- ✅ `EmailService` - Servicio de envío con dos modos:
+ - **Desarrollo**: Logs en consola
+ - **Producción**: Envío real vía SMTP (JavaMail)
+- ✅ `AuthController` - Integración completa del flujo de verificación
+
+### 3. **Frontend**
+- ✅ Página de verificación (`verifyEmail.scala.html`) con:
+ - Input especializado para código de 3 dígitos
+ - Diseño moderno con gradiente púrpura
+ - Indicador de tiempo de expiración
+ - Mensajes de error claros
+ - Botón de reenvío de código
+ - Tarjeta de ayuda con tips
+- ✅ Estilos SCSS personalizados (`_verification.scss`)
+- ✅ Animaciones y efectos visuales
+
+### 4. **Emails HTML**
+- ✅ Template profesional para código de verificación:
+ - Diseño responsive
+ - Código destacado en formato grande
+ - Información clara sobre expiración y límites
+ - Estilos inline para compatibilidad universal
+- ✅ Template de bienvenida post-verificación
+- ✅ Branding consistente con el sitio
+
+---
+
+## 🔄 Flujo de Usuario
+
+```
+1. Usuario se registra / intenta iniciar sesión
+ ↓
+2. Sistema verifica si email está verificado
+ ↓ (si NO está verificado)
+3. Sistema genera código de 3 dígitos (100-999)
+ ↓
+4. Código se envía por email (o se muestra en consola en dev)
+ ↓
+5. Usuario ingresa el código en la página de verificación
+ ↓
+6. Sistema valida:
+ - ¿Código correcto?
+ - ¿No expirado? (< 5 minutos)
+ - ¿Intentos disponibles? (< 3)
+ ↓
+7a. ✅ Código válido → Usuario verificado → Redirige al dashboard
+7b. ❌ Código inválido → Incrementa intentos → Muestra error
+7c. 🔄 Código expirado/agotado → Usuario puede solicitar nuevo código
+```
+
+---
+
+## 🎮 Modos de Operación
+
+### 🧪 Modo Desarrollo (Predeterminado)
+```conf
+email.enabled = false
+```
+- **Ventaja**: No requiere configuración SMTP
+- **Comportamiento**: Códigos se muestran en consola
+- **Uso**: Ideal para desarrollo local y testing
+
+**Ejemplo de log:**
+```
+========================================
+ 📧 CÓDIGO DE VERIFICACIÓN (DEV MODE)
+========================================
+ Email: usuario@example.com
+ Código: 456
+ Expira en: 5 minutos
+========================================
+```
+
+### 🌐 Modo Producción
+```conf
+email.enabled = true
+email.smtp.host = "smtp.gmail.com"
+email.smtp.port = 587
+email.smtp.user = ${?EMAIL_USER}
+email.smtp.password = ${?EMAIL_PASSWORD}
+```
+- **Ventaja**: Emails reales a usuarios
+- **Requisito**: Configuración SMTP válida
+- **Soporta**: Gmail, SendGrid, Mailgun, AWS SES, etc.
+
+---
+
+## 📁 Archivos Creados/Modificados
+
+### Nuevos Archivos
+```
+conf/evolutions/default/5.sql # Migración BD
+app/models/EmailVerificationCode.scala # Modelo
+app/repositories/EmailVerificationRepository.scala # Repositorio
+app/services/EmailVerificationService.scala # Lógica de negocio
+app/services/EmailService.scala # Envío de emails
+app/views/auth/verifyEmail.scala.html # Vista
+app/assets/stylesheets/components/_verification.scss # Estilos
+resource/EMAIL_CONFIGURATION.md # Documentación
+test-email.sh # Script de prueba
+```
+
+### Archivos Modificados
+```
+app/models/User.scala # Campo emailVerified
+app/repositories/UserRepository.scala # Método updateEmailVerified
+app/controllers/AuthController.scala # Flujo de verificación
+conf/routes # 3 nuevas rutas
+conf/application.conf # Configuración email
+build.sbt # Dependencia JavaMail
+app/assets/stylesheets/main.scss # Import de estilos
+```
+
+---
+
+## 🔧 Configuración Rápida para Gmail
+
+### Paso 1: Habilitar en Producción
+Edita `conf/application.conf`:
+```conf
+email.enabled = true
+email.smtp.host = "smtp.gmail.com"
+email.smtp.port = 587
+email.smtp.user = ${?EMAIL_USER}
+email.smtp.password = ${?EMAIL_PASSWORD}
+email.from = "tu-email@gmail.com"
+email.fromName = "Reactive Manifesto"
+```
+
+### Paso 2: Generar Contraseña de Aplicación
+1. Ve a https://myaccount.google.com/security
+2. Habilita "Verificación en 2 pasos"
+3. Ve a https://myaccount.google.com/apppasswords
+4. Genera contraseña para "Correo"
+5. Copia la contraseña de 16 caracteres
+
+### Paso 3: Configurar Variables de Entorno
+```bash
+export EMAIL_USER="tu-email@gmail.com"
+export EMAIL_PASSWORD="xxxx-xxxx-xxxx-xxxx"
+```
+
+### Paso 4: Reiniciar Aplicación
+```bash
+sbt run
+```
+
+---
+
+## 🧪 Cómo Probar
+
+### Opción 1: Script Automático
+```bash
+./test-email.sh
+```
+
+### Opción 2: Manual
+```bash
+# En modo desarrollo (predeterminado)
+sbt run
+
+# Luego en el navegador:
+# 1. Registra un nuevo usuario
+# 2. El código aparecerá en la consola
+# 3. Ingresa el código en la página de verificación
+```
+
+---
+
+## 📊 Seguridad Implementada
+
+- ✅ **Códigos aleatorios**: Generación criptográficamente segura
+- ✅ **Expiración temporal**: 5 minutos de validez
+- ✅ **Límite de intentos**: Máximo 3 intentos por código
+- ✅ **Códigos de un solo uso**: Se marcan como usados después de verificar
+- ✅ **Limpieza automática**: Códigos expirados se eliminan
+- ✅ **Variables de entorno**: Credenciales nunca en código
+- ✅ **Protección CSRF**: Integrado con Play Framework
+
+---
+
+## 📈 Métricas de Implementación
+
+| Componente | Líneas de Código | Estado |
+|------------|------------------|--------|
+| Modelos | ~30 | ✅ Completo |
+| Repositorio | ~120 | ✅ Completo |
+| Servicios | ~250 | ✅ Completo |
+| Controller | ~80 | ✅ Completo |
+| Vistas | ~150 | ✅ Completo |
+| Estilos | ~200 | ✅ Completo |
+| Migraciones | ~25 | ✅ Completo |
+| **TOTAL** | **~855** | **✅ Completo** |
+
+---
+
+## 🎯 Próximos Pasos (Opcionales)
+
+### Mejoras Sugeridas
+- [ ] Panel de administración para ver verificaciones pendientes
+- [ ] Estadísticas de tasa de verificación
+- [ ] Recordatorios automáticos por email
+- [ ] Verificación por SMS como alternativa
+- [ ] Logs de auditoría de verificaciones
+
+### Optimizaciones
+- [ ] Cache de códigos en Redis
+- [ ] Rate limiting para evitar spam
+- [ ] Blacklist de emails temporales
+- [ ] Internacionalización completa (i18n)
+
+---
+
+## 📚 Documentación Completa
+
+- **Configuración Email**: [resource/EMAIL_CONFIGURATION.md](resource/EMAIL_CONFIGURATION.md)
+- **Autenticación**: [resource/SISTEMA_AUTENTICACION.md](resource/SISTEMA_AUTENTICACION.md)
+- **Instalación**: [resource/INSTALLATION.md](resource/INSTALLATION.md)
+
+---
+
+## ✅ Estado Final
+
+**Sistema 100% Funcional y Listo para Producción** 🎉
+
+- ✅ Compilación exitosa
+- ✅ Migraciones aplicadas
+- ✅ Modo desarrollo funcionando
+- ✅ Modo producción configurado
+- ✅ Documentación completa
+- ✅ Scripts de prueba incluidos
+
+---
+
+**Desarrollado para Reactive Manifesto**
+*Sistema de verificación por email con códigos temporales de 3 dígitos*
+
+Fecha de implementación: Enero 8, 2026
diff --git a/resource/FLUJO_VERIFICACION_EMAIL.md b/resource/FLUJO_VERIFICACION_EMAIL.md
new file mode 100644
index 0000000..17283aa
--- /dev/null
+++ b/resource/FLUJO_VERIFICACION_EMAIL.md
@@ -0,0 +1,198 @@
+# Flujo de Verificación de Email
+
+## 📋 Descripción General
+
+El sistema de registro requiere verificación de email antes de permitir el acceso completo al usuario.
+
+## 🔄 Flujo Completo
+
+### 1. Registro de Usuario
+- Usuario completa formulario en `/register`
+- Sistema crea usuario con `emailVerified = false`
+- Redirige a `/login` con mensaje: "Registro exitoso. Por favor inicia sesión para verificar tu email."
+
+### 2. Primer Login (Sin Verificar)
+- Usuario ingresa credenciales en `/login`
+- Sistema valida username y password
+- Detecta que `emailVerified = false`
+- Genera código de 3 dígitos aleatorio (100-999)
+- **En modo desarrollo**: Imprime código en logs
+- **En modo producción**: Envía código por email
+- Redirige a `/verify-email/:userId`
+
+### 3. Verificación de Código
+- Usuario ve página con formulario de verificación
+- Ingresa código de 3 dígitos
+- Sistema valida:
+ - ✅ Código existe
+ - ✅ No ha expirado (5 minutos)
+ - ✅ No ha excedido intentos (máximo 3)
+ - ✅ Código coincide
+- Si válido:
+ - Marca `emailVerified = true` en la base de datos
+ - Crea sesión de usuario
+ - Actualiza `lastLogin`
+ - Redirige a `/dashboard` con mensaje de bienvenida
+
+### 4. Logins Posteriores
+- Una vez verificado, el login es directo al dashboard
+- No se requiere re-verificación
+
+## 🛠️ Modo Desarrollo
+
+La aplicación está configurada en modo desarrollo con `email.enabled = false`.
+
+### Ver Códigos de Verificación
+
+Cuando un usuario intenta hacer login sin verificar, el código se imprime en los logs del servidor:
+
+```bash
+# Ejecutar la aplicación
+sbt run
+
+# El código aparecerá así:
+========================================
+ 📧 CÓDIGO DE VERIFICACIÓN (DEV MODE)
+========================================
+ Email: usuario@example.com
+ Código: 456
+ Expira en: 5 minutos
+========================================
+```
+
+### Prueba Completa
+
+1. **Iniciar servidor:**
+ ```bash
+ sbt run
+ ```
+
+2. **Registrar usuario:**
+ - Ir a http://localhost:9000/register
+ - Completar formulario
+ - Click en "Registrarse"
+
+3. **Hacer login:**
+ - Ir a http://localhost:9000/login
+ - Ingresar credenciales
+ - Click en "Iniciar Sesión"
+
+4. **Ver código en logs:**
+ - Revisar la terminal donde está corriendo `sbt run`
+ - Buscar el bloque con "CÓDIGO DE VERIFICACIÓN"
+ - Copiar el código de 3 dígitos
+
+5. **Verificar email:**
+ - Serás redirigido automáticamente a `/verify-email/:userId`
+ - Ingresar el código de 3 dígitos
+ - Click en "Verificar Código"
+
+6. **Acceso completo:**
+ - Ahora tienes acceso al dashboard
+ - Login futuro será directo
+
+## 🚀 Modo Producción
+
+Para habilitar envío real de emails:
+
+1. **Configurar variables de entorno:**
+ ```bash
+ export EMAIL_USER="tu-email@gmail.com"
+ export EMAIL_PASSWORD="tu-app-password"
+ ```
+
+2. **Habilitar emails en application.conf:**
+ ```properties
+ email.enabled = true
+ ```
+
+3. **Configurar Gmail:**
+ - Habilitar verificación en 2 pasos
+ - Generar "Contraseña de aplicación" en https://myaccount.google.com/apppasswords
+ - Usar esa contraseña en `EMAIL_PASSWORD`
+
+## 🔧 Endpoints
+
+| Ruta | Método | Descripción |
+|------|--------|-------------|
+| `/register` | GET | Muestra formulario de registro |
+| `/register` | POST | Procesa registro de usuario |
+| `/login` | GET | Muestra formulario de login |
+| `/login` | POST | Procesa login y envía código si no verificado |
+| `/verify-email/:userId` | GET | Muestra formulario de verificación |
+| `/verify-email` | POST | Procesa código de verificación |
+| `/resend-code/:userId` | GET | Reenvía código de verificación |
+| `/dashboard` | GET | Dashboard de usuario (requiere verificación) |
+
+## 📊 Base de Datos
+
+### Tabla: users
+```sql
+CREATE TABLE users (
+ id BIGSERIAL PRIMARY KEY,
+ username VARCHAR(255) UNIQUE NOT NULL,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ full_name VARCHAR(255) NOT NULL,
+ role VARCHAR(50) DEFAULT 'user',
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP NOT NULL,
+ last_login TIMESTAMP,
+ email_verified BOOLEAN DEFAULT false -- ← Campo clave
+);
+```
+
+### Tabla: email_verification_codes
+```sql
+CREATE TABLE email_verification_codes (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ code VARCHAR(3) NOT NULL,
+ attempts INT DEFAULT 0,
+ verified BOOLEAN DEFAULT false,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL
+);
+```
+
+## 🐛 Troubleshooting
+
+### Usuario no puede hacer login después del registro
+
+**Síntoma:** Usuario se registra pero el login falla o redirige constantemente.
+
+**Causa:** Usuario no ha verificado su email.
+
+**Solución:**
+1. Hacer login → Te redirige a página de verificación
+2. Ver código en logs (modo desarrollo)
+3. Ingresar código en formulario
+4. Verificar y acceder al dashboard
+
+### No veo el código en los logs
+
+**Verificar:**
+- Terminal donde corre `sbt run` está visible
+- `email.enabled = false` en application.conf
+- Reiniciar servidor después de cambios
+
+### Código expiró
+
+**Solución:**
+- Click en "Reenviar Código" en la página de verificación
+- Se generará un nuevo código válido por 5 minutos
+
+### Demasiados intentos fallidos
+
+**Solución:**
+- Click en "Reenviar Código" para obtener un código nuevo
+- El contador de intentos se reinicia
+
+## 📝 Notas de Seguridad
+
+- Códigos de 3 dígitos: 1000 combinaciones posibles
+- Expiración: 5 minutos
+- Máximo 3 intentos por código
+- BCrypt para passwords (salt rounds: 10)
+- Sesiones basadas en cookies HTTP
diff --git a/resource/GUIA_CREAR_PUBLICACION.md b/resource/GUIA_CREAR_PUBLICACION.md
new file mode 100644
index 0000000..66e4c94
--- /dev/null
+++ b/resource/GUIA_CREAR_PUBLICACION.md
@@ -0,0 +1,240 @@
+# 📝 Guía: Cómo Crear una Publicación
+
+## 🎯 Flujo Completo de Publicaciones
+
+### 1️⃣ Acceder al Dashboard de Usuario
+
+**Opción A: Iniciar sesión como usuario**
+1. Ve a: http://localhost:9000/login
+2. Ingresa tus credenciales de usuario
+3. Serás redirigido automáticamente a: http://localhost:9000/user/dashboard
+
+**Opción B: Iniciar sesión como admin**
+1. Ve a: http://localhost:9000/admin/login
+2. Usuario: `federico`
+3. Contraseña: `admin123`
+
+---
+
+### 2️⃣ Crear Nueva Publicación
+
+#### Desde el Dashboard:
+Verás un banner de bienvenida con el botón:
+```
+✍️ Crear Nueva Publicación
+```
+
+Al hacer clic, serás redirigido a:
+```
+http://localhost:9000/user/publications/new
+```
+
+#### Formulario de Publicación:
+
+**Campos obligatorios:**
+- **Título** (5-200 caracteres)
+- **Contenido** (mínimo 50 caracteres, soporta Markdown)
+- **Categoría** (Scala, Akka, Play Framework, etc.)
+
+**Campos opcionales:**
+- **Extracto** (resumen de hasta 500 caracteres)
+- **Imagen de portada** (URL de la imagen)
+- **Tags** (separados por comas: reactive,scala,functional)
+
+#### Ejemplo de contenido Markdown:
+```markdown
+# Mi Primera Publicación
+
+Este es un párrafo introductorio sobre programación reactiva.
+
+## Conceptos Clave
+
+- Asincronía
+- Resiliencia
+- Elasticidad
+
+```scala
+val future = Future {
+ // Código asíncrono
+}
+```
+
+---
+
+### 3️⃣ Estados de una Publicación
+
+| Estado | Descripción | Acciones Disponibles |
+|--------|-------------|---------------------|
+| **draft** | Borrador inicial | ✏️ Editar, 📤 Enviar a Revisión, 🗑️ Eliminar |
+| **pending** | En revisión por admin | 👁️ Ver solamente |
+| **approved** | Publicada (visible públicamente) | 👁️ Ver solamente |
+| **rejected** | Rechazada con comentario | 👁️ Ver, ✏️ Editar, 📤 Re-enviar |
+
+---
+
+### 4️⃣ Workflow Completo
+
+```
+1. Usuario crea publicación (estado: draft)
+ ↓
+2. Usuario edita y revisa el contenido
+ ↓
+3. Usuario envía para revisión (estado: pending)
+ ↓
+4. Admin revisa la publicación
+ ↓
+5a. Admin aprueba (estado: approved) ✅
+ - Publicación visible en /publicaciones
+
+5b. Admin rechaza (estado: rejected) ❌
+ - Usuario puede ver el motivo
+ - Usuario puede editar y re-enviar
+```
+
+---
+
+### 5️⃣ Rutas Disponibles
+
+#### Usuario:
+- `GET /user/dashboard` - Dashboard con todas las publicaciones
+- `GET /user/publications/new` - Formulario para crear publicación
+- `POST /user/publications/new` - Guardar nueva publicación
+- `GET /user/publications/:id` - Ver detalle de publicación
+- `GET /user/publications/:id/edit` - Editar publicación
+- `POST /user/publications/:id/edit` - Guardar cambios
+- `POST /user/publications/:id/submit` - Enviar para revisión
+- `POST /user/publications/:id/delete` - Eliminar publicación
+
+#### Admin:
+- `GET /admin/publications/pending` - Ver publicaciones pendientes
+- `GET /admin/publications/:id/review` - Revisar publicación
+- `POST /admin/publications/:id/approve` - Aprobar publicación
+- `POST /admin/publications/:id/reject` - Rechazar con motivo
+
+---
+
+### 6️⃣ Tips y Mejores Prácticas
+
+#### Para el contenido:
+- ✅ Usa Markdown para formatear tu contenido
+- ✅ Incluye ejemplos de código con bloques ```scala
+- ✅ Divide en secciones con encabezados (##, ###)
+- ✅ Agrega un extracto atractivo (se muestra en la lista)
+
+#### Para las categorías:
+- Scala
+- Akka
+- Play Framework
+- Reactive Programming
+- Functional Programming
+- Microservices
+- Testing
+
+#### Para los tags:
+Separa con comas, ejemplo:
+```
+scala, reactive, actors, concurrency
+```
+
+---
+
+### 7️⃣ Verificar que todo funciona
+
+#### Test rápido:
+```bash
+# Crear un usuario de prueba
+curl -X POST http://localhost:9000/register \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=testuser&email=test@example.com&password=Test123&confirmPassword=Test123"
+
+# Ver admins disponibles
+curl http://localhost:9000/setup/list-admins | python3 -m json.tool
+```
+
+#### Credenciales Admin:
+- Usuario: `federico`
+- Contraseña: `admin123`
+- Login: http://localhost:9000/admin/login
+
+---
+
+### 8️⃣ Características del Dashboard
+
+#### Banner de Bienvenida:
+- Saludo personalizado con nombre de usuario
+- Botón principal: **"✍️ Crear Nueva Publicación"**
+- Botón secundario: **"👤 Mi Perfil"**
+
+#### Estadísticas:
+- 📝 Borradores
+- ⏳ Pendientes de revisión
+- ✅ Aprobadas
+- ❌ Rechazadas
+
+#### Tabla de Publicaciones:
+- Título, Categoría, Estado, Fecha
+- Acciones contextuales según estado
+- Iconos visuales para cada acción
+
+#### Estado Vacío:
+Si no tienes publicaciones, verás:
+- Icono grande 📝
+- Mensaje motivacional
+- Botón CTA: **"✍️ Crear Mi Primera Publicación"**
+
+---
+
+## 🎨 Capturas de Pantalla del Flujo
+
+### Dashboard Vacío:
+```
+┌────────────────────────────────────────┐
+│ 👋 Hola, usuario │
+│ Gestiona tus publicaciones │
+│ │
+│ [✍️ Crear Nueva Publicación] │
+│ [👤 Mi Perfil] │
+└────────────────────────────────────────┘
+
+┌─────┬─────┬─────┬─────┐
+│📝 0 │⏳ 0 │✅ 0 │❌ 0 │
+└─────┴─────┴─────┴─────┘
+
+ 📝
+ No tienes publicaciones aún
+ Comienza creando tu primera publicación
+
+ [✍️ Crear Mi Primera Publicación]
+```
+
+### Dashboard con Publicaciones:
+```
+┌────────────────────────────────────────┐
+│ Título │ Estado │ Acciones│
+├────────────────────────────────────────┤
+│ Mi Primer Post │ draft │👁️ ✏️ 📤 🗑️│
+│ Tutorial Akka │ pending │👁️ │
+│ Intro a Scala │approved │👁️ │
+└────────────────────────────────────────┘
+```
+
+---
+
+## 🚀 ¡Listo para Empezar!
+
+1. Inicia sesión en: http://localhost:9000/login
+2. Haz clic en **"✍️ Crear Nueva Publicación"**
+3. Completa el formulario
+4. Guarda como borrador
+5. Edita y mejora tu contenido
+6. Envía para revisión cuando esté listo
+7. El admin aprobará o rechazará tu publicación
+
+---
+
+## 📧 Soporte
+
+¿Problemas? Revisa:
+- El servidor está corriendo en puerto 9000
+- Las rutas están correctamente configuradas
+- Los admins están creados (usa `/setup/list-admins`)
diff --git a/resource/PROBLEMA_EMAIL_VERIFICACION.md b/resource/PROBLEMA_EMAIL_VERIFICACION.md
new file mode 100644
index 0000000..95516d7
--- /dev/null
+++ b/resource/PROBLEMA_EMAIL_VERIFICACION.md
@@ -0,0 +1,188 @@
+# Problema: No llega el código de verificación
+
+## 📋 Diagnóstico
+
+El sistema de verificación por email **está funcionando correctamente**, pero el envío de emails está **deshabilitado** por configuración.
+
+### Estado Actual
+
+```properties
+# conf/application.conf línea 94
+email.enabled = false # ❌ DESHABILITADO
+```
+
+## 🔍 ¿Qué está pasando?
+
+Cuando `email.enabled = false`:
+1. ✅ El código de verificación SÍ se genera (3 dígitos aleatorios)
+2. ✅ El código SÍ se guarda en la base de datos
+3. ✅ El código expira en 5 minutos
+4. ❌ El email NO se envía
+5. ✅ El código se imprime en los **logs del servidor** para desarrollo
+
+### Ver el código en los logs
+
+El código aparece en la consola del servidor con este formato:
+
+```
+========================================
+📧 CÓDIGO DE VERIFICACIÓN (DEV MODE)
+========================================
+Email: usuario@ejemplo.com
+Código: 456
+Expira en: 5 minutos
+========================================
+```
+
+## ✅ Soluciones
+
+### Opción 1: Modo Desarrollo (Usar logs)
+
+**Para desarrollo local sin configurar Gmail:**
+
+1. Mantén `email.enabled = false`
+2. Inicia el servidor: `sbt run`
+3. Registra un usuario o solicita código
+4. **Busca el código en los logs del servidor**
+5. Ingresa el código en la aplicación
+
+### Opción 2: Habilitar Gmail (Producción)
+
+**Para enviar emails reales:**
+
+#### Paso 1: Configurar cuenta Gmail
+
+1. Ve a tu cuenta de Google: https://myaccount.google.com/
+2. Habilita **"Verificación en 2 pasos"**
+3. Genera una **"Contraseña de aplicación"**:
+ - Ve a: https://myaccount.google.com/apppasswords
+ - Selecciona "Mail" y "Other (Custom name)"
+ - Nombra: "Reactive Manifesto"
+ - Copia la contraseña de 16 caracteres
+
+#### Paso 2: Configurar variables de entorno
+
+```bash
+# Linux/Mac - Agrega a ~/.bashrc o ~/.zshrc
+export EMAIL_USER="tu-email@gmail.com"
+export EMAIL_PASSWORD="xxxx xxxx xxxx xxxx" # Contraseña de app de 16 dígitos
+
+# Windows - CMD
+set EMAIL_USER=tu-email@gmail.com
+set EMAIL_PASSWORD=xxxx xxxx xxxx xxxx
+
+# Windows - PowerShell
+$env:EMAIL_USER="tu-email@gmail.com"
+$env:EMAIL_PASSWORD="xxxx xxxx xxxx xxxx"
+```
+
+#### Paso 3: Habilitar el envío de emails
+
+```properties
+# conf/application.conf
+email.enabled = true # ✅ HABILITADO
+```
+
+#### Paso 4: Reiniciar el servidor
+
+```bash
+sbt run
+```
+
+### Opción 3: Desactivar verificación por email (Solo desarrollo)
+
+**⚠️ NO RECOMENDADO para producción**
+
+Si quieres saltarte la verificación temporalmente:
+
+1. Modifica `AuthController.scala`
+2. Comenta la validación de email en el registro
+3. Marca usuarios como verificados automáticamente
+
+## 🧪 Probar el sistema
+
+### Test 1: Verificar logs (Modo desarrollo)
+
+```bash
+# Terminal 1: Inicia el servidor
+sbt run
+
+# Terminal 2: Registra un usuario
+curl -X POST http://localhost:9000/auth/register \
+ -H "Content-Type: application/json" \
+ -d '{
+ "username": "test",
+ "email": "test@example.com",
+ "password": "123456",
+ "fullName": "Test User"
+ }'
+
+# Busca el código en los logs del Terminal 1
+```
+
+### Test 2: Verificar Gmail (Modo producción)
+
+```bash
+# Configura las variables de entorno
+export EMAIL_USER="tu-email@gmail.com"
+export EMAIL_PASSWORD="tu-contraseña-de-app"
+
+# Edita application.conf
+# email.enabled = true
+
+# Inicia el servidor
+sbt run
+
+# Registra un usuario
+# Revisa tu bandeja de entrada
+```
+
+## 📁 Archivos Relacionados
+
+- **Configuración**: [conf/application.conf](conf/application.conf) línea 94
+- **Servicio de Email**: [app/services/EmailService.scala](app/services/EmailService.scala)
+- **Servicio de Verificación**: [app/services/EmailVerificationService.scala](app/services/EmailVerificationService.scala)
+- **Controlador**: [app/controllers/AuthController.scala](app/controllers/AuthController.scala)
+- **Guía detallada**: [CONFIGURAR_GMAIL.md](CONFIGURAR_GMAIL.md)
+
+## 📚 Recursos Adicionales
+
+- [Google App Passwords](https://myaccount.google.com/apppasswords)
+- [Gmail SMTP Settings](https://support.google.com/mail/answer/7126229)
+- [Play Framework Email Configuration](https://www.playframework.com/documentation/latest/ScalaMail)
+
+## 🐛 Troubleshooting
+
+### El código no aparece en los logs
+
+**Problema**: No veo el código impreso en la consola
+
+**Solución**:
+1. Verifica que `email.enabled = false`
+2. Busca líneas que contengan "CÓDIGO DE VERIFICACIÓN"
+3. Verifica el nivel de log en `conf/logback.xml`
+
+### Gmail rechaza el login
+
+**Problema**: `Authentication failed: 535 Username and Password not accepted`
+
+**Soluciones**:
+1. ✅ Usa una **contraseña de aplicación**, NO tu contraseña normal
+2. ✅ Habilita "Verificación en 2 pasos"
+3. ✅ Verifica que `EMAIL_USER` sea tu email completo
+4. ✅ Verifica que `EMAIL_PASSWORD` tenga los 16 caracteres (sin espacios en el código)
+
+### El código expira muy rápido
+
+**Problema**: El código expira antes de poder usarlo
+
+**Solución**: Modifica `CODE_EXPIRATION_MINUTES` en `EmailVerificationService.scala`:
+
+```scala
+private val CODE_EXPIRATION_MINUTES = 10 // Cambia de 5 a 10 minutos
+```
+
+## 🎯 Recomendación
+
+**Para desarrollo local**: Usa **Opción 1** (logs)
+**Para producción**: Usa **Opción 2** (Gmail configurado)
diff --git a/resource/PUBLICATIONS_SYSTEM.md b/resource/PUBLICATIONS_SYSTEM.md
new file mode 100644
index 0000000..4ed1c62
--- /dev/null
+++ b/resource/PUBLICATIONS_SYSTEM.md
@@ -0,0 +1,360 @@
+# 📝 Sistema de Publicaciones de Usuarios
+
+## Descripción General
+
+Sistema completo de gestión de publicaciones que permite a los usuarios crear contenido y a los administradores aprobar/rechazar publicaciones antes de su publicación.
+
+## 🎯 Funcionalidades Implementadas
+
+### Para Usuarios Regulares
+
+1. **Dashboard Personal** (`/user/dashboard`)
+ - Ver todas sus publicaciones
+ - Estadísticas por estado (borradores, pendientes, aprobadas, rechazadas)
+ - Acciones rápidas (editar, enviar, eliminar)
+
+2. **Crear Publicaciones** (`/user/publications/new`)
+ - Título (5-200 caracteres)
+ - Categoría (Scala, Akka, Play Framework, etc.)
+ - Contenido (mínimo 50 caracteres, soporta Markdown)
+ - Extracto opcional (500 caracteres)
+ - Etiquetas (separadas por comas)
+ - Imagen de portada (URL)
+
+3. **Editar Publicaciones** (`/user/publications/:id/edit`)
+ - Modificar cualquier campo
+ - Solo publicaciones propias
+ - Solo si están en estado borrador o rechazadas
+
+4. **Flujo de Aprobación**
+ - **Borrador (draft)**: Estado inicial, solo visible para el autor
+ - **Pendiente (pending)**: Enviada para revisión de administradores
+ - **Aprobada (approved)**: Visible públicamente
+ - **Rechazada (rejected)**: Con motivo del rechazo
+
+5. **Vista Previa** (`/user/publications/:id`)
+ - Ver cómo se verá la publicación
+ - Disponible en cualquier estado
+
+### Para Administradores
+
+1. **Panel de Revisión** (`/admin/publications/pending`)
+ - Lista de todas las publicaciones pendientes
+ - Vista tipo card con información resumida
+ - Acciones rápidas de aprobación/rechazo
+
+2. **Detalle de Publicación** (`/admin/publications/:id`)
+ - Vista completa del contenido
+ - Información del autor
+ - Barra de acciones fija en la parte inferior
+ - Aprobar o rechazar con motivo
+
+3. **Aprobar Publicaciones** (`POST /admin/publications/:id/approve`)
+ - Cambia estado a "approved"
+ - Establece fecha de publicación
+ - Registra el revisor
+
+4. **Rechazar Publicaciones** (`POST /admin/publications/:id/reject`)
+ - Cambia estado a "rejected"
+ - Requiere motivo del rechazo
+ - El usuario puede ver el motivo y corregir
+
+## 📁 Estructura de Archivos
+
+### Modelos
+- `app/models/Publication.scala` - Modelo de publicación con estados
+
+### Repositorios
+- `app/repositories/PublicationRepository.scala` - Operaciones de base de datos
+
+### Controladores
+- `app/controllers/UserPublicationController.scala` - CRUD para usuarios
+- `app/controllers/AdminController.scala` - Extensión con aprobación de publicaciones
+
+### Actions (Autenticación)
+- `app/controllers/actions/AuthAction.scala`
+ - `AuthAction` - Usuario autenticado (cualquier rol)
+ - `UserAction` - Usuario con rol "user" o "admin"
+ - `AdminOnlyAction` - Solo administradores
+ - `OptionalAuthAction` - Autenticación opcional
+
+### Vistas
+- `app/views/user/dashboard.scala.html` - Dashboard del usuario
+- `app/views/user/publicationForm.scala.html` - Formulario crear/editar
+- `app/views/user/publicationPreview.scala.html` - Vista previa
+- `app/views/admin/publicationReview.scala.html` - Lista de pendientes
+- `app/views/admin/publicationDetail.scala.html` - Detalle para revisar
+
+### Base de Datos
+- `sql/publications_management.sql` - Script SQL completo
+- `conf/evolutions/default/6.sql` - Migración para Play Framework
+
+## 🗃️ Esquema de Base de Datos
+
+```sql
+CREATE TABLE publications (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ title VARCHAR(200) NOT NULL,
+ slug VARCHAR(250) NOT NULL UNIQUE,
+ content TEXT NOT NULL,
+ excerpt VARCHAR(500),
+ cover_image VARCHAR(500),
+ category VARCHAR(100) NOT NULL,
+ tags VARCHAR(500),
+ status VARCHAR(20) NOT NULL DEFAULT 'draft',
+ view_count INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ published_at TIMESTAMP,
+ reviewed_by BIGINT,
+ reviewed_at TIMESTAMP,
+ rejection_reason TEXT,
+
+ CONSTRAINT fk_publication_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_publication_reviewer FOREIGN KEY (reviewed_by) REFERENCES admins(id) ON DELETE SET NULL,
+ CONSTRAINT chk_status CHECK (status IN ('draft', 'pending', 'approved', 'rejected'))
+);
+```
+
+## 🚀 Instalación y Configuración
+
+### 1. Ejecutar Migración de Base de Datos
+
+**Opción A: Usando Play Evolutions (Recomendado)**
+- La migración se ejecutará automáticamente al iniciar la aplicación
+- Archivo: `conf/evolutions/default/6.sql`
+
+**Opción B: Manualmente**
+```bash
+psql -U usuario -d nombre_db -f sql/publications_management.sql
+```
+
+### 2. Configurar Rutas
+
+Las rutas ya están configuradas en `conf/routes`:
+
+**Rutas de Usuario:**
+- `GET /user/dashboard` - Dashboard
+- `GET /user/publications/new` - Formulario nueva publicación
+- `POST /user/publications/new` - Crear publicación
+- `GET /user/publications/:id/edit` - Editar publicación
+- `POST /user/publications/:id/edit` - Actualizar publicación
+- `GET /user/publications/:id` - Ver publicación
+- `POST /user/publications/:id/submit` - Enviar para revisión
+- `POST /user/publications/:id/delete` - Eliminar publicación
+
+**Rutas de Admin:**
+- `GET /admin/publications/pending` - Ver pendientes
+- `GET /admin/publications/:id` - Detalle para revisar
+- `POST /admin/publications/:id/approve` - Aprobar
+- `POST /admin/publications/:id/reject` - Rechazar
+
+### 3. Iniciar la Aplicación
+
+```bash
+sbt run
+```
+
+## 📊 Flujo de Trabajo
+
+### Usuario Crea Publicación
+```
+1. Usuario → /user/dashboard
+2. Click "Nueva Publicación"
+3. Completar formulario
+4. Guardar como "Borrador"
+5. Editar si es necesario
+6. Click "Enviar para Revisión" (estado → pending)
+```
+
+### Admin Revisa Publicación
+```
+1. Admin → /admin/publications/pending
+2. Ver lista de publicaciones pendientes
+3. Click "Ver Completa" en una publicación
+4. Revisar contenido
+5. Aprobar o Rechazar:
+ - Aprobar → Estado: approved, visible públicamente
+ - Rechazar → Estado: rejected, con motivo
+```
+
+### Usuario Recibe Feedback
+```
+- Si aprobada: Ver en dashboard con badge verde "Aprobada"
+- Si rechazada: Ver motivo, puede editar y reenviar
+```
+
+## 🎨 Diseño y Estilos
+
+### Dashboard de Usuario
+- **Colores**: Grises profesionales con acentos azules
+- **Cards de estadísticas**: Una por cada estado
+- **Tabla responsive**: Con acciones contextuales
+- **Badges de estado**: Colores semánticos
+ - Draft: Gris
+ - Pending: Amarillo
+ - Approved: Verde
+ - Rejected: Rojo
+
+### Panel de Admin
+- **Header azul corporativo**: Con degradado
+- **Cards de publicaciones**: Hover con elevación
+- **Barra de acciones fija**: En detalle de publicación
+- **Modal de rechazo**: Para especificar motivo
+
+## 🔐 Seguridad
+
+### Actions de Autorización
+```scala
+// Solo usuarios autenticados (user o admin)
+def dashboard = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ // ...
+}
+
+// Solo administradores
+def pendingPublications = Action.async { implicit request =>
+ if (!isAdmin(request)) {
+ Future.successful(Redirect(routes.AdminController.loginPage()))
+ } else {
+ // ...
+ }
+}
+```
+
+### Validaciones
+- Usuario solo puede editar/eliminar sus propias publicaciones
+- Admin puede ver todas las publicaciones
+- Slug único generado automáticamente
+- CSRF tokens en todos los formularios
+
+## 📈 Características Adicionales
+
+### 1. Estadísticas
+```scala
+publicationRepo.getUserStats(userId)
+// Retorna: Map("draft" -> 3, "pending" -> 2, "approved" -> 10)
+```
+
+### 2. Búsqueda por Categoría
+```scala
+publicationRepo.findByCategory("Scala", limit = 20)
+```
+
+### 3. Contador de Vistas
+```scala
+publicationRepo.incrementViewCount(publicationId)
+```
+
+### 4. API JSON
+- `GET /api/user/publications` - Publicaciones del usuario
+- `GET /api/admin/publications` - Todas las publicaciones (admin)
+
+## 🧪 Testing
+
+### Probar el Sistema
+
+1. **Crear un usuario regular**:
+```bash
+# Registrarse en /register
+```
+
+2. **Crear publicaciones de prueba**:
+```bash
+# Navegar a /user/dashboard
+# Click "Nueva Publicación"
+# Completar formulario y guardar
+```
+
+3. **Enviar para revisión**:
+```bash
+# En el dashboard, click "Enviar" en una publicación
+```
+
+4. **Login como admin**:
+```bash
+# Ir a /admin/login
+```
+
+5. **Revisar publicaciones**:
+```bash
+# Ir a /admin/publications/pending
+# Aprobar o rechazar publicaciones
+```
+
+## 🔄 Próximas Mejoras Sugeridas
+
+1. **Editor Markdown Rico**
+ - Integrar editor WYSIWYG (SimpleMDE, TUI Editor)
+ - Preview en tiempo real
+
+2. **Sistema de Comentarios**
+ - Comentarios de admin en las revisiones
+ - Historial de cambios
+
+3. **Notificaciones**
+ - Email cuando se aprueba/rechaza una publicación
+ - Notificaciones en tiempo real
+
+4. **Búsqueda y Filtros**
+ - Búsqueda full-text en publicaciones
+ - Filtrar por categoría, tags, estado
+
+5. **Versioning**
+ - Guardar versiones anteriores de publicaciones
+ - Comparar cambios
+
+6. **Analytics**
+ - Métricas de visualizaciones
+ - Tiempo de lectura estimado
+ - Publicaciones más populares
+
+## 📝 Notas Técnicas
+
+### Generación de Slugs
+Los slugs se generan automáticamente a partir del título:
+- Convierte a minúsculas
+- Reemplaza caracteres especiales
+- Agrega timestamp para garantizar unicidad
+
+### Estados de Publicación
+```scala
+object PublicationStatus extends Enumeration {
+ val Draft = Value("draft") // Borrador
+ val Pending = Value("pending") // En revisión
+ val Approved = Value("approved") // Aprobada
+ val Rejected = Value("rejected") // Rechazada
+}
+```
+
+### Triggers de Base de Datos
+- `updated_at` se actualiza automáticamente en cada UPDATE
+- Función PostgreSQL para mantener timestamps
+
+## 🆘 Troubleshooting
+
+### Error: No se puede crear publicación
+- Verificar que la tabla `publications` existe
+- Verificar que el usuario está autenticado
+- Revisar logs de Play Framework
+
+### Error: Admin no puede ver publicaciones pendientes
+- Verificar que el admin está logueado
+- Verificar método `isAdmin()` en AdminController
+- Revisar sesión del usuario
+
+### Error: Publicaciones no se actualizan
+- Verificar que el `user_id` coincide
+- Revisar permisos en el repositorio
+- Verificar trigger de `updated_at`
+
+## 📞 Soporte
+
+Para más información o reportar issues:
+- Revisar logs en consola de Play
+- Verificar configuración de base de datos
+- Revisar rutas en `conf/routes`
+
+---
+
+**¡Sistema de Publicaciones implementado exitosamente! 🎉**
diff --git a/resource/email-preview.html b/resource/email-preview.html
new file mode 100644
index 0000000..9ce855a
--- /dev/null
+++ b/resource/email-preview.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+ Vista Previa - Email de Verificación
+
+
+
+
+
+
+
+
+
+
+ Email 1: Código de Verificación
+
+
+
+
+
+
Hola,
+
Usa el siguiente código para verificar tu cuenta en Reactive Manifesto:
+
+
+
+
+
⏱️ Este código expira en 5 minutos
+
Si no solicitaste este código, puedes ignorar este email.
+
Por seguridad, tienes máximo 3 intentos para ingresar el código correcto.
+
+
+
+
+
+
+
+
+
+ Email 2: Bienvenida (Enviado después de verificar)
+
+
+
+
+
+
+
👋 Hola, Federico Pfund
+
Tu cuenta ha sido verificada exitosamente
+
+
+
+
Ahora puedes acceder a todo el contenido exclusivo de Reactive Manifesto:
+
+ - 📚 Artículos sobre programación reactiva
+ - 💼 Proyectos del portafolio
+ - 📊 Demos interactivas
+ - 📄 Documentación técnica
+
+
¡Esperamos que disfrutes explorando el mundo de la programación reactiva!
+
+
+
+
+
+
+
+
+
ℹ️ Información Técnica
+
+
Características de los Emails:
+
+ - ✅ Diseño Responsive: Se adapta a móviles y escritorio
+ - ✅ HTML Inline CSS: Compatible con todos los clientes de email
+ - ✅ Gradientes Modernos: Diseño visual atractivo
+ - ✅ Tipografía Clara: Código en formato monoespaciado grande
+ - ✅ Información Completa: Tiempo de expiración y límites claros
+
+
+
Cuándo se Envían:
+
+ - Código de Verificación: Cuando un usuario se registra o intenta iniciar sesión sin verificar su email
+ - Email de Bienvenida: Después de que el usuario verifica exitosamente su código
+
+
+
Configuración Actual:
+
+ - Modo Desarrollo: Los códigos aparecen en la consola
+ - Modo Producción: Los emails se envían vía SMTP
+ - Para activar envío real:
email.enabled = true en application.conf
+
+
+
+
+
From eed3a75be6b7fc7331bb469d537f946e3677c136 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:24:19 +0000
Subject: [PATCH 12/45] =?UTF-8?q?feat(controllers):=20implementar=20autent?=
=?UTF-8?q?icaci=C3=B3n=20unificada=20con=20login=20por=20rol,=20registro?=
=?UTF-8?q?=20y=20verificaci=C3=B3n=20de=20email?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/controllers/AuthController.scala | 150 ++++++++++++++++++++++++---
1 file changed, 133 insertions(+), 17 deletions(-)
diff --git a/app/controllers/AuthController.scala b/app/controllers/AuthController.scala
index fb2820c..3b7f88e 100644
--- a/app/controllers/AuthController.scala
+++ b/app/controllers/AuthController.scala
@@ -4,21 +4,25 @@ import javax.inject._
import play.api.mvc._
import play.api.data._
import play.api.data.Forms._
+import play.api.i18n.I18nSupport
import scala.concurrent.{ExecutionContext, Future}
import repositories.{UserRepository, AdminRepository}
+import services.EmailVerificationService
import models.User
import org.mindrot.jbcrypt.BCrypt
import java.time.Instant
case class UserLoginForm(username: String, password: String, loginType: String)
case class UserRegisterForm(username: String, email: String, password: String, confirmPassword: String, fullName: String)
+case class EmailVerificationForm(userId: Long, code: String)
@Singleton
class AuthController @Inject()(
cc: ControllerComponents,
userRepository: UserRepository,
- adminRepository: AdminRepository
-)(implicit ec: ExecutionContext) extends AbstractController(cc) {
+ adminRepository: AdminRepository,
+ emailVerificationService: EmailVerificationService
+)(implicit ec: ExecutionContext) extends AbstractController(cc) with I18nSupport {
// Formulario de login unificado
val loginForm = Form(
@@ -43,13 +47,23 @@ class AuthController @Inject()(
})
)
+ // Formulario de verificación de email
+ val verificationForm = Form(
+ mapping(
+ "userId" -> longNumber,
+ "code" -> nonEmptyText(minLength = 3, maxLength = 3)
+ )(EmailVerificationForm.apply)(EmailVerificationForm.unapply)
+ )
+
// Helpers de autenticación
private def isUserAuthenticated(request: RequestHeader): Boolean = {
request.session.get("userId").isDefined
}
private def isAdminAuthenticated(request: RequestHeader): Boolean = {
- request.session.get("adminId").isDefined
+ request.session.get("userId").exists(_ =>
+ request.session.get("userRole").contains("admin")
+ )
}
private def withUserAuth(block: => Future[Result])(implicit request: RequestHeader): Future[Result] = {
@@ -64,13 +78,14 @@ class AuthController @Inject()(
* Página de login unificada (usuarios y admins)
*/
def loginPage(): Action[AnyContent] = Action { implicit request =>
- // Si ya está logueado como usuario
- if (isUserAuthenticated(request)) {
- Redirect(routes.AuthController.userDashboard())
- } else if (isAdminAuthenticated(request)) {
- Redirect(routes.AdminController.dashboard(0, None))
- } else {
- Ok(views.html.auth.login(loginForm))
+ // Si ya está logueado, redirigir según el rol
+ request.session.get("userId") match {
+ case Some(_) =>
+ request.session.get("userRole") match {
+ case Some("admin") => Redirect(routes.AdminController.dashboard(0, None))
+ case _ => Redirect(routes.AuthController.userDashboard())
+ }
+ case None => Ok(views.html.auth.login(loginForm))
}
}
@@ -100,10 +115,19 @@ class AuthController @Inject()(
private def authenticateUser(username: String, password: String)(implicit request: RequestHeader): Future[Result] = {
userRepository.findByUsername(username).flatMap {
case Some(user) if BCrypt.checkpw(password, user.passwordHash) =>
- userRepository.updateLastLogin(user.id.get).map { _ =>
- Redirect(routes.AuthController.userDashboard())
- .withSession("userId" -> user.id.get.toString, "username" -> user.username, "userRole" -> user.role)
- .flashing("success" -> s"Bienvenido, ${user.fullName}")
+ if (!user.emailVerified) {
+ // Usuario no verificado, enviar código
+ emailVerificationService.createAndSendCode(user.id.get, user.email).map { _ =>
+ Redirect(routes.AuthController.verifyEmailPage(user.id.get))
+ .flashing("info" -> "Por favor verifica tu email para continuar")
+ }
+ } else {
+ // Usuario verificado, login normal
+ userRepository.updateLastLogin(user.id.get).map { _ =>
+ Redirect(routes.AuthController.userDashboard())
+ .withSession("userId" -> user.id.get.toString, "username" -> user.username, "userRole" -> user.role)
+ .flashing("success" -> s"Bienvenido, ${user.fullName}")
+ }
}
case _ =>
Future.successful(
@@ -120,7 +144,11 @@ class AuthController @Inject()(
case Some(admin) if BCrypt.checkpw(password, admin.passwordHash) =>
adminRepository.updateLastLogin(admin.id.get).map { _ =>
Redirect(routes.AdminController.dashboard(0, None))
- .withSession("adminId" -> admin.id.get.toString, "adminUsername" -> admin.username)
+ .withSession(
+ "userId" -> admin.id.get.toString,
+ "username" -> admin.username,
+ "userRole" -> "admin"
+ )
.flashing("success" -> s"Bienvenido Admin, ${admin.username}")
}
case _ =>
@@ -174,12 +202,13 @@ class AuthController @Inject()(
role = "user",
isActive = true,
createdAt = Instant.now(),
- lastLogin = None
+ lastLogin = None,
+ emailVerified = false // Requiere verificación por código
)
userRepository.create(newUser).map { _ =>
Redirect(routes.AuthController.loginPage())
- .flashing("success" -> "Registro exitoso. Por favor inicia sesión.")
+ .flashing("success" -> "Registro exitoso. Por favor inicia sesión para verificar tu email.")
}
}
}
@@ -223,4 +252,91 @@ class AuthController @Inject()(
}
}
}
+
+ /**
+ * Página de verificación de email
+ */
+ def verifyEmailPage(userId: Long): Action[AnyContent] = Action.async { implicit request =>
+ userRepository.findById(userId).map {
+ case Some(user) =>
+ Ok(views.html.auth.verifyEmail(user.email, userId, None))
+ case None =>
+ Redirect(routes.AuthController.loginPage())
+ .flashing("error" -> "Usuario no encontrado")
+ }
+ }
+
+ /**
+ * Procesar verificación de código
+ */
+ def verifyEmailCode(): Action[AnyContent] = Action.async { implicit request =>
+ verificationForm.bindFromRequest().fold(
+ formWithErrors => {
+ Future.successful(BadRequest("Formulario inválido"))
+ },
+ verificationData => {
+ emailVerificationService.verifyCode(verificationData.userId, verificationData.code).flatMap {
+ case Right(true) =>
+ // Código válido, actualizar usuario y hacer login
+ for {
+ _ <- userRepository.updateEmailVerified(verificationData.userId, true)
+ user <- userRepository.findById(verificationData.userId)
+ _ <- userRepository.updateLastLogin(verificationData.userId)
+ } yield {
+ user match {
+ case Some(u) =>
+ Redirect(routes.AuthController.userDashboard())
+ .withSession("userId" -> u.id.get.toString, "username" -> u.username, "userRole" -> u.role)
+ .flashing("success" -> "¡Email verificado exitosamente! Bienvenido")
+ case None =>
+ Redirect(routes.AuthController.loginPage())
+ .flashing("error" -> "Error al verificar email")
+ }
+ }
+
+ case Right(false) =>
+ // Este caso no debería ocurrir según la lógica del servicio, pero lo manejamos por completitud
+ userRepository.findById(verificationData.userId).map { user =>
+ Ok(views.html.auth.verifyEmail(
+ user.map(_.email).getOrElse(""),
+ verificationData.userId,
+ Some("Error inesperado al verificar el código")
+ ))
+ }
+
+ case Left(error) =>
+ // Código inválido o error
+ userRepository.findById(verificationData.userId).map {
+ case Some(user) =>
+ Ok(views.html.auth.verifyEmail(user.email, verificationData.userId, Some(error)))
+ case None =>
+ Redirect(routes.AuthController.loginPage())
+ .flashing("error" -> "Usuario no encontrado")
+ }
+ }
+ }
+ )
+ }
+
+ /**
+ * Reenviar código de verificación
+ */
+ def resendVerificationCode(userId: Long): Action[AnyContent] = Action.async { implicit request =>
+ userRepository.findById(userId).flatMap {
+ case Some(user) =>
+ emailVerificationService.createAndSendCode(userId, user.email).map { _ =>
+ Redirect(routes.AuthController.verifyEmailPage(userId))
+ .flashing("success" -> "Código reenviado. Revisa tu email")
+ }.recover {
+ case ex: Exception =>
+ Redirect(routes.AuthController.verifyEmailPage(userId))
+ .flashing("error" -> s"Error al reenviar código: ${ex.getMessage}")
+ }
+ case None =>
+ Future.successful(
+ Redirect(routes.AuthController.loginPage())
+ .flashing("error" -> "Usuario no encontrado")
+ )
+ }
+ }
}
From 7511d037fce7fc3ade8f2c598a98dadcd3fb4698 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:24:51 +0000
Subject: [PATCH 13/45] feat(controllers): implementar HomeController con
publicaciones, portafolio y formulario de contacto reactivo
---
app/controllers/HomeController.scala | 46 +++++++++++++++++++---------
1 file changed, 32 insertions(+), 14 deletions(-)
diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala
index c0e3747..f6cbbb0 100644
--- a/app/controllers/HomeController.scala
+++ b/app/controllers/HomeController.scala
@@ -9,6 +9,7 @@ import play.api.i18n.I18nSupport
import services.ReactiveContactAdapter
import repositories.ContactRepository
import core.{Contact, ContactSubmitted, ContactError}
+import actions.{OptionalAuthAction, OptionalAuthRequest}
import scala.concurrent.{ExecutionContext, Future}
// Form data case class (outside controller for Twirl template access)
@@ -18,7 +19,9 @@ case class ContactFormData(name: String, email: String, message: String)
class HomeController @Inject()(
val controllerComponents: ControllerComponents,
adapter: ReactiveContactAdapter,
- contactRepository: ContactRepository
+ contactRepository: ContactRepository,
+ publicationRepository: repositories.PublicationRepository,
+ optionalAuth: OptionalAuthAction
)(implicit ec: ExecutionContext) extends BaseController with I18nSupport {
// Form definition
@@ -35,24 +38,39 @@ class HomeController @Inject()(
Ok(views.html.index(contactForm))
}
- def publicaciones() = Action { implicit request: Request[AnyContent] =>
- Ok(views.html.publicaciones())
+ def publicaciones() = Action.async { implicit request: Request[AnyContent] =>
+ // Obtener publicaciones dinámicas aprobadas de usuarios
+ publicationRepository.findAllApproved(limit = 20).map { dynamicPublications =>
+ Ok(views.html.publicaciones(dynamicPublications))
+ }
}
- def publicacion(slug: String) = Action { implicit request: Request[AnyContent] =>
- slug match {
- case "akka-actors" => Ok(views.html.articulos.akkaActors())
- case "patrones-resiliencia" => Ok(views.html.articulos.patronesResiliencia())
- case "akka-streams" => Ok(views.html.articulos.akkaStreams())
- case "play-async" => Ok(views.html.articulos.playAsync())
- case "message-passing" => Ok(views.html.articulos.messagePassing())
- case "testing-reactivo" => Ok(views.html.articulos.testingReactivo())
- case _ => NotFound("Publicación no encontrada")
+ def publicacion(slug: String) = Action.async { implicit request: Request[AnyContent] =>
+ // Primero buscar en publicaciones dinámicas
+ publicationRepository.findBySlug(slug).map {
+ case Some(publication) if publication.status == "approved" =>
+ // Incrementar contador de vistas
+ publicationRepository.incrementViewCount(publication.id.get)
+ Ok(views.html.user.publicationPreview(publication, "Invitado"))
+ case _ =>
+ // Si no se encuentra, buscar en artículos estáticos
+ slug match {
+ case "akka-actors" => Ok(views.html.articulos.akkaActors())
+ case "patrones-resiliencia" => Ok(views.html.articulos.patronesResiliencia())
+ case "akka-streams" => Ok(views.html.articulos.akkaStreams())
+ case "play-async" => Ok(views.html.articulos.playAsync())
+ case "message-passing" => Ok(views.html.articulos.messagePassing())
+ case "testing-reactivo" => Ok(views.html.articulos.testingReactivo())
+ case _ => NotFound("Publicación no encontrada")
+ }
}
}
- def portafolio() = Action { implicit request: Request[AnyContent] =>
- Ok(views.html.portafolio())
+ def portafolio() = optionalAuth { implicit request: OptionalAuthRequest[AnyContent] =>
+ // Pasar información de autenticación a la vista
+ val isAuthenticated = request.userInfo.isDefined
+ val username = request.userInfo.map(_._2)
+ Ok(views.html.portafolio(isAuthenticated, username))
}
def submitContact() = Action.async { implicit request: Request[AnyContent] =>
From 1feb7da734869b2aa3c859c7debe8c8aec2a12d4 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:25:35 +0000
Subject: [PATCH 14/45] =?UTF-8?q?feat(controllers):=20agregar=20SetupContr?=
=?UTF-8?q?oller=20para=20inicializaci=C3=B3n=20segura=20de=20administrado?=
=?UTF-8?q?res=20en=20entorno=20controlado?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/controllers/SetupController.scala | 164 ++++++++++++++++++++++++++
1 file changed, 164 insertions(+)
create mode 100644 app/controllers/SetupController.scala
diff --git a/app/controllers/SetupController.scala b/app/controllers/SetupController.scala
new file mode 100644
index 0000000..93b06d3
--- /dev/null
+++ b/app/controllers/SetupController.scala
@@ -0,0 +1,164 @@
+package controllers
+
+import javax.inject._
+import play.api.mvc._
+import play.api.{Configuration, Environment}
+import repositories.AdminRepository
+import models.Admin
+import scala.concurrent.{ExecutionContext, Future}
+import play.api.libs.json.Json
+
+@Singleton
+class SetupController @Inject()(
+ cc: ControllerComponents,
+ adminRepo: AdminRepository,
+ config: Configuration,
+ env: Environment
+)(implicit ec: ExecutionContext) extends AbstractController(cc) {
+
+ // Solo habilitar en modo desarrollo o si la variable de entorno SETUP_ENABLED está activada
+ private def isSetupEnabled: Boolean = {
+ env.mode == play.api.Mode.Dev || config.getOptional[Boolean]("setup.enabled").getOrElse(false)
+ }
+
+ private def withSetupAccess(block: => Future[Result]): Future[Result] = {
+ if (isSetupEnabled) {
+ block
+ } else {
+ Future.successful(Forbidden(Json.obj(
+ "error" -> "Setup endpoints están deshabilitados en producción",
+ "message" -> "Por razones de seguridad, estos endpoints solo están disponibles en modo desarrollo"
+ )))
+ }
+ }
+
+ /**
+ * Endpoint temporal para crear admin inicial
+ * Acceder a: http://localhost:9000/setup/create-admin
+ */
+ def createInitialAdmin(): Action[AnyContent] = Action.async { implicit request =>
+ withSetupAccess {
+ val adminData = Admin(
+ id = None,
+ username = "federico",
+ email = "federico@reactivemanifesto.com",
+ passwordHash = "$2a$10$So8GceVpZX3J2ZX4ARqViuj9ldnk3uupjDGWGk9kReFufCpup3m1C",
+ role = "admin",
+ createdAt = java.time.Instant.now(),
+ lastLogin = None
+ )
+
+ adminRepo.findByUsername("federico").flatMap {
+ case Some(_) =>
+ Future.successful(Ok(Json.obj(
+ "success" -> false,
+ "message" -> "El admin 'federico' ya existe",
+ "action" -> "Usa /setup/list-admins para ver los admins existentes"
+ )))
+ case None =>
+ adminRepo.create(adminData).map { admin =>
+ Ok(Json.obj(
+ "success" -> true,
+ "message" -> "Admin creado exitosamente",
+ "admin" -> Json.obj(
+ "id" -> admin.id,
+ "username" -> admin.username,
+ "email" -> admin.email,
+ "role" -> admin.role
+ ),
+ "credentials" -> Json.obj(
+ "username" -> "federico",
+ "password" -> "Fede/(40021)",
+ "loginUrl" -> "/admin/login"
+ )
+ ))
+ }
+ }
+ }
+ }
+
+ /**
+ * Listar todos los admins
+ * Acceder a: http://localhost:9000/setup/list-admins
+ */
+ def listAdmins(): Action[AnyContent] = Action.async { implicit request =>
+ withSetupAccess {
+ adminRepo.listAll().map { admins =>
+ Ok(Json.obj(
+ "success" -> true,
+ "count" -> admins.length,
+ "admins" -> Json.toJson(admins.map { admin =>
+ Json.obj(
+ "id" -> admin.id,
+ "username" -> admin.username,
+ "email" -> admin.email,
+ "role" -> admin.role,
+ "createdAt" -> admin.createdAt.toString,
+ "lastLogin" -> admin.lastLogin.map(_.toString).getOrElse[String]("Nunca")
+ )
+ })
+ ))
+ } } }
+
+ /**
+ * Actualizar contraseña de un admin
+ * Acceder a: http://localhost:9000/setup/update-password?username=admin&password=admin123
+ */
+ def updatePassword(username: String, password: String): Action[AnyContent] = Action.async { implicit request =>
+ withSetupAccess {
+ import org.mindrot.jbcrypt.BCrypt
+
+ val newHash = BCrypt.hashpw(password, BCrypt.gensalt())
+
+ adminRepo.findByUsername(username).flatMap {
+ case Some(admin) =>
+ adminRepo.updatePassword(admin.id.get, newHash).map { _ =>
+ Ok(Json.obj(
+ "success" -> true,
+ "message" -> s"Contraseña actualizada para '$username'",
+ "credentials" -> Json.obj(
+ "username" -> username,
+ "password" -> password,
+ "loginUrl" -> "/admin/login"
+ )
+ ))
+ }
+ case None =>
+ Future.successful(NotFound(Json.obj(
+ "success" -> false,
+ "message" -> s"Admin '$username' no encontrado"
+ )))
+ }
+ }
+ }
+
+ /**
+ * Probar login de admin
+ * Acceder a: http://localhost:9000/setup/test-login?username=admin&password=admin123
+ */
+ def testLogin(username: String, password: String): Action[AnyContent] = Action.async { implicit request =>
+ withSetupAccess {
+ import org.mindrot.jbcrypt.BCrypt
+
+ adminRepo.findByUsername(username).map {
+ case Some(admin) =>
+ val passwordMatch = BCrypt.checkpw(password, admin.passwordHash)
+ Ok(Json.obj(
+ "success" -> passwordMatch,
+ "message" -> (if (passwordMatch) "Credenciales válidas" else "Contraseña incorrecta"),
+ "admin" -> Json.obj(
+ "id" -> admin.id,
+ "username" -> admin.username,
+ "email" -> admin.email,
+ "role" -> admin.role
+ )
+ ))
+ case None =>
+ NotFound(Json.obj(
+ "success" -> false,
+ "message" -> s"Admin '$username' no encontrado"
+ ))
+ }
+ }
+ }
+}
From 8d88d908d3c3fb2d8d4838fbe68f8b4d01bd9dba Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:25:55 +0000
Subject: [PATCH 15/45] =?UTF-8?q?feat(controllers):=20implementar=20gesti?=
=?UTF-8?q?=C3=B3n=20de=20publicaciones=20de=20usuario=20con=20dashboard,?=
=?UTF-8?q?=20edici=C3=B3n=20y=20env=C3=ADo=20a=20revisi=C3=B3n?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UserPublicationController.scala | 266 ++++++++++++++++++
1 file changed, 266 insertions(+)
create mode 100644 app/controllers/UserPublicationController.scala
diff --git a/app/controllers/UserPublicationController.scala b/app/controllers/UserPublicationController.scala
new file mode 100644
index 0000000..b891ebb
--- /dev/null
+++ b/app/controllers/UserPublicationController.scala
@@ -0,0 +1,266 @@
+package controllers
+
+import javax.inject._
+import play.api.mvc._
+import play.api.data._
+import play.api.data.Forms._
+import play.api.libs.json._
+import scala.concurrent.{ExecutionContext, Future}
+import repositories.{PublicationRepository, UserRepository}
+import models.{Publication, PublicationStatus}
+import actions.{UserAction, AuthRequest}
+import java.time.Instant
+
+case class PublicationFormData(
+ title: String,
+ content: String,
+ excerpt: Option[String],
+ coverImage: Option[String],
+ category: String,
+ tags: Option[String]
+)
+
+@Singleton
+class UserPublicationController @Inject()(
+ cc: ControllerComponents,
+ publicationRepo: PublicationRepository,
+ userRepo: UserRepository,
+ userAction: UserAction
+)(implicit ec: ExecutionContext) extends AbstractController(cc) {
+
+ // Definición del formulario
+ val publicationForm = Form(
+ mapping(
+ "title" -> nonEmptyText(minLength = 5, maxLength = 200),
+ "content" -> nonEmptyText(minLength = 50),
+ "excerpt" -> optional(text(maxLength = 500)),
+ "coverImage" -> optional(text),
+ "category" -> nonEmptyText,
+ "tags" -> optional(text)
+ )(PublicationFormData.apply)(PublicationFormData.unapply)
+ )
+
+ /**
+ * Dashboard del usuario - Ver todas sus publicaciones
+ */
+ def dashboard = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ for {
+ publications <- publicationRepo.findByUserId(request.userId)
+ stats <- publicationRepo.getUserStats(request.userId)
+ } yield {
+ Ok(views.html.user.dashboard(
+ username = request.username,
+ publications = publications,
+ stats = stats
+ ))
+ }
+ }
+
+ /**
+ * Formulario para crear nueva publicación
+ */
+ def newPublicationForm = userAction { implicit request: AuthRequest[AnyContent] =>
+ Ok(views.html.user.publicationForm(
+ publicationForm,
+ None,
+ request.username
+ ))
+ }
+
+ /**
+ * Crear nueva publicación
+ */
+ def createPublication = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationForm.bindFromRequest().fold(
+ formWithErrors => {
+ Future.successful(
+ BadRequest(views.html.user.publicationForm(
+ formWithErrors,
+ None,
+ request.username
+ ))
+ )
+ },
+ formData => {
+ val slug = generateSlug(formData.title)
+ val publication = Publication(
+ userId = request.userId,
+ title = formData.title,
+ slug = slug,
+ content = formData.content,
+ excerpt = formData.excerpt,
+ coverImage = formData.coverImage,
+ category = formData.category,
+ tags = formData.tags,
+ status = PublicationStatus.Draft.toString
+ )
+
+ publicationRepo.create(publication).map { id =>
+ Redirect(routes.UserPublicationController.editPublicationForm(id))
+ .flashing("success" -> "Publicación creada exitosamente como borrador")
+ }
+ }
+ )
+ }
+
+ /**
+ * Formulario para editar publicación existente
+ */
+ def editPublicationForm(id: Long) = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepo.findById(id).map {
+ case Some(publication) if publication.userId == request.userId =>
+ val filledForm = publicationForm.fill(PublicationFormData(
+ title = publication.title,
+ content = publication.content,
+ excerpt = publication.excerpt,
+ coverImage = publication.coverImage,
+ category = publication.category,
+ tags = publication.tags
+ ))
+ Ok(views.html.user.publicationForm(
+ filledForm,
+ Some(publication),
+ request.username
+ ))
+ case Some(_) =>
+ Forbidden("No tienes permiso para editar esta publicación")
+ case None =>
+ NotFound("Publicación no encontrada")
+ }
+ }
+
+ /**
+ * Actualizar publicación
+ */
+ def updatePublication(id: Long) = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepo.findById(id).flatMap {
+ case Some(existingPub) if existingPub.userId == request.userId =>
+ publicationForm.bindFromRequest().fold(
+ formWithErrors => {
+ Future.successful(
+ BadRequest(views.html.user.publicationForm(
+ formWithErrors,
+ Some(existingPub),
+ request.username
+ ))
+ )
+ },
+ formData => {
+ val slug = generateSlug(formData.title)
+ val updatedPub = existingPub.copy(
+ title = formData.title,
+ slug = slug,
+ content = formData.content,
+ excerpt = formData.excerpt,
+ coverImage = formData.coverImage,
+ category = formData.category,
+ tags = formData.tags,
+ updatedAt = Instant.now()
+ )
+
+ publicationRepo.update(updatedPub).map { success =>
+ if (success) {
+ Redirect(routes.UserPublicationController.dashboard())
+ .flashing("success" -> "Publicación actualizada exitosamente")
+ } else {
+ InternalServerError("Error al actualizar la publicación")
+ }
+ }
+ }
+ )
+ case Some(_) =>
+ Future.successful(Forbidden("No tienes permiso para editar esta publicación"))
+ case None =>
+ Future.successful(NotFound("Publicación no encontrada"))
+ }
+ }
+
+ /**
+ * Enviar publicación para revisión
+ */
+ def submitForReview(id: Long) = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepo.findById(id).flatMap {
+ case Some(publication) if publication.userId == request.userId =>
+ val updated = publication.copy(
+ status = PublicationStatus.Pending.toString,
+ updatedAt = Instant.now()
+ )
+ publicationRepo.update(updated).map { success =>
+ if (success) {
+ Redirect(routes.UserPublicationController.dashboard())
+ .flashing("success" -> "Publicación enviada para revisión")
+ } else {
+ InternalServerError("Error al enviar la publicación")
+ }
+ }
+ case Some(_) =>
+ Future.successful(Forbidden("No tienes permiso"))
+ case None =>
+ Future.successful(NotFound("Publicación no encontrada"))
+ }
+ }
+
+ /**
+ * Eliminar publicación
+ */
+ def deletePublication(id: Long) = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepo.delete(id, request.userId).map { success =>
+ if (success) {
+ Redirect(routes.UserPublicationController.dashboard())
+ .flashing("success" -> "Publicación eliminada")
+ } else {
+ BadRequest("No se pudo eliminar la publicación")
+ }
+ }
+ }
+
+ /**
+ * Ver publicación (preview)
+ */
+ def viewPublication(id: Long) = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepo.findById(id).map {
+ case Some(publication) if publication.userId == request.userId || publication.status == PublicationStatus.Approved.toString =>
+ Ok(views.html.user.publicationPreview(publication, request.username))
+ case Some(_) =>
+ Forbidden("No tienes permiso para ver esta publicación")
+ case None =>
+ NotFound("Publicación no encontrada")
+ }
+ }
+
+ /**
+ * API: Listar publicaciones del usuario (JSON)
+ */
+ def listPublicationsJson = userAction.async { implicit request: AuthRequest[AnyContent] =>
+ publicationRepo.findByUserId(request.userId).map { publications =>
+ Ok(Json.toJson(publications.map { pub =>
+ Json.obj(
+ "id" -> pub.id,
+ "title" -> pub.title,
+ "status" -> pub.status,
+ "category" -> pub.category,
+ "viewCount" -> pub.viewCount,
+ "createdAt" -> pub.createdAt.toString,
+ "updatedAt" -> pub.updatedAt.toString
+ )
+ }))
+ }
+ }
+
+ /**
+ * Generar slug a partir del título
+ */
+ private def generateSlug(title: String): String = {
+ val slug = title.toLowerCase
+ .replaceAll("[áàäâ]", "a")
+ .replaceAll("[éèëê]", "e")
+ .replaceAll("[íìïî]", "i")
+ .replaceAll("[óòöô]", "o")
+ .replaceAll("[úùüû]", "u")
+ .replaceAll("[ñ]", "n")
+ .replaceAll("[^a-z0-9]+", "-")
+ .replaceAll("^-|-$", "")
+
+ s"$slug-${System.currentTimeMillis()}"
+ }
+}
From 5079e0d245cd1198361104185e869ddc363c9925 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:26:17 +0000
Subject: [PATCH 16/45] =?UTF-8?q?feat(actions):=20implementar=20acciones?=
=?UTF-8?q?=20de=20autenticaci=C3=B3n=20y=20autorizaci=C3=B3n=20por=20rol?=
=?UTF-8?q?=20(auth,=20user=20y=20admin)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/controllers/actions/AuthAction.scala | 145 +++++++++++++++++++++++
1 file changed, 145 insertions(+)
create mode 100644 app/controllers/actions/AuthAction.scala
diff --git a/app/controllers/actions/AuthAction.scala b/app/controllers/actions/AuthAction.scala
new file mode 100644
index 0000000..b9d404b
--- /dev/null
+++ b/app/controllers/actions/AuthAction.scala
@@ -0,0 +1,145 @@
+package controllers.actions
+
+import javax.inject.Inject
+import play.api.mvc._
+import scala.concurrent.{ExecutionContext, Future}
+
+/**
+ * AuthAction - Verifica si un usuario está autenticado
+ *
+ * Uso:
+ * def protectedRoute = AuthAction { implicit request =>
+ * Ok(s"Bienvenido, ${request.userId}")
+ * }
+ */
+case class AuthRequest[A](userId: Long, username: String, role: String, request: Request[A])
+ extends WrappedRequest[A](request)
+
+class AuthAction @Inject()(
+ val parser: BodyParsers.Default,
+ val executionContext: ExecutionContext
+) extends ActionBuilder[AuthRequest, AnyContent] {
+
+ override def invokeBlock[A](
+ request: Request[A],
+ block: AuthRequest[A] => Future[Result]
+ ): Future[Result] = {
+ request.session.get("userId") match {
+ case Some(userIdStr) =>
+ val userId = userIdStr.toLong
+ val username = request.session.get("username").getOrElse("Usuario")
+ val role = request.session.get("userRole").getOrElse("user")
+ block(AuthRequest(userId, username, role, request))
+
+ case None =>
+ // Redirigir a login con URL de retorno
+ val redirectUrl = request.uri
+ Future.successful(
+ Results.Redirect(controllers.routes.AuthController.loginPage())
+ .flashing(
+ "error" -> "Debes iniciar sesión para acceder a este recurso",
+ "redirectUrl" -> redirectUrl
+ )
+ )
+ }
+ }
+}
+
+/**
+ * OptionalAuthAction - Acción opcional que puede incluir datos de usuario si está logueado
+ * Útil para páginas que quieren mostrar contenido diferente según el estado de auth
+ */
+case class OptionalAuthRequest[A](
+ userInfo: Option[(Long, String, String)], // (userId, username, role)
+ request: Request[A]
+) extends WrappedRequest[A](request)
+
+class OptionalAuthAction @Inject()(
+ val parser: BodyParsers.Default,
+ val executionContext: ExecutionContext
+) extends ActionBuilder[OptionalAuthRequest, AnyContent] {
+
+ override def invokeBlock[A](
+ request: Request[A],
+ block: OptionalAuthRequest[A] => Future[Result]
+ ): Future[Result] = {
+ val userInfo = for {
+ userIdStr <- request.session.get("userId")
+ userId = userIdStr.toLong
+ username <- request.session.get("username")
+ role <- request.session.get("userRole")
+ } yield (userId, username, role)
+
+ block(OptionalAuthRequest(userInfo, request))
+ }
+}
+
+/**
+ * UserAction - Verifica que el usuario está autenticado y tiene rol de usuario o admin
+ */
+class UserAction @Inject()(
+ val parser: BodyParsers.Default,
+ val executionContext: ExecutionContext
+) extends ActionBuilder[AuthRequest, AnyContent] {
+
+ override def invokeBlock[A](
+ request: Request[A],
+ block: AuthRequest[A] => Future[Result]
+ ): Future[Result] = {
+ request.session.get("userId") match {
+ case Some(userIdStr) =>
+ val userId = userIdStr.toLong
+ val username = request.session.get("username").getOrElse("Usuario")
+ val role = request.session.get("userRole").getOrElse("user")
+
+ if (role == "user" || role == "admin") {
+ block(AuthRequest(userId, username, role, request))
+ } else {
+ Future.successful(
+ Results.Forbidden("No tienes permisos para acceder a este recurso")
+ )
+ }
+
+ case None =>
+ Future.successful(
+ Results.Redirect(controllers.routes.AuthController.loginPage())
+ .flashing("error" -> "Debes iniciar sesión para acceder")
+ )
+ }
+ }
+}
+
+/**
+ * AdminOnlyAction - Verifica que el usuario es administrador
+ */
+class AdminOnlyAction @Inject()(
+ val parser: BodyParsers.Default,
+ val executionContext: ExecutionContext
+) extends ActionBuilder[AuthRequest, AnyContent] {
+
+ override def invokeBlock[A](
+ request: Request[A],
+ block: AuthRequest[A] => Future[Result]
+ ): Future[Result] = {
+ request.session.get("userId") match {
+ case Some(userIdStr) =>
+ val userId = userIdStr.toLong
+ val username = request.session.get("username").getOrElse("Admin")
+ val role = request.session.get("userRole").getOrElse("user")
+
+ if (role == "admin") {
+ block(AuthRequest(userId, username, role, request))
+ } else {
+ Future.successful(
+ Results.Forbidden("Solo los administradores pueden acceder a este recurso")
+ )
+ }
+
+ case None =>
+ Future.successful(
+ Results.Redirect(controllers.routes.AdminController.loginPage())
+ .flashing("error" -> "Debes iniciar sesión como administrador")
+ )
+ }
+ }
+}
From a6dea9373c9dbc1b07f239324b3c5f05f6a57f3c Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:26:35 +0000
Subject: [PATCH 17/45] =?UTF-8?q?feat(models):=20agregar=20modelo=20EmailV?=
=?UTF-8?q?erificationCode=20para=20verificaci=C3=B3n=20de=20correo=20elec?=
=?UTF-8?q?tr=C3=B3nico?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/models/EmailVerificationCode.scala | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 app/models/EmailVerificationCode.scala
diff --git a/app/models/EmailVerificationCode.scala b/app/models/EmailVerificationCode.scala
new file mode 100644
index 0000000..8645e33
--- /dev/null
+++ b/app/models/EmailVerificationCode.scala
@@ -0,0 +1,17 @@
+package models
+
+import java.time.Instant
+
+case class EmailVerificationCode(
+ id: Option[Long] = None,
+ userId: Long,
+ email: String,
+ code: String,
+ createdAt: Instant = Instant.now(),
+ expiresAt: Instant,
+ verified: Boolean = false,
+ attempts: Int = 0
+) {
+ def isExpired: Boolean = Instant.now().isAfter(expiresAt)
+ def canAttempt: Boolean = attempts < 3 && !isExpired
+}
From ade37d92e91686a04dea798800e7f197ccb675b8 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:26:58 +0000
Subject: [PATCH 18/45] =?UTF-8?q?feat(models):=20agregar=20modelo=20de=20p?=
=?UTF-8?q?ublicaciones=20con=20estados,=20auditor=C3=ADa=20y=20datos=20de?=
=?UTF-8?q?=20autor?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/models/Publication.scala | 45 ++++++++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 app/models/Publication.scala
diff --git a/app/models/Publication.scala b/app/models/Publication.scala
new file mode 100644
index 0000000..5df5255
--- /dev/null
+++ b/app/models/Publication.scala
@@ -0,0 +1,45 @@
+package models
+
+import java.time.Instant
+
+/**
+ * Estados de una publicación:
+ * - draft: Borrador, no visible
+ * - pending: Enviado para revisión
+ * - approved: Aprobado por admin, visible públicamente
+ * - rejected: Rechazado por admin
+ */
+object PublicationStatus extends Enumeration {
+ type PublicationStatus = Value
+ val Draft = Value("draft")
+ val Pending = Value("pending")
+ val Approved = Value("approved")
+ val Rejected = Value("rejected")
+}
+
+case class Publication(
+ id: Option[Long] = None,
+ userId: Long,
+ title: String,
+ slug: String,
+ content: String,
+ excerpt: Option[String] = None,
+ coverImage: Option[String] = None,
+ category: String,
+ tags: Option[String] = None, // Almacenado como CSV: "scala,akka,reactive"
+ status: String = PublicationStatus.Draft.toString,
+ viewCount: Int = 0,
+ createdAt: Instant = Instant.now(),
+ updatedAt: Instant = Instant.now(),
+ publishedAt: Option[Instant] = None,
+ reviewedBy: Option[Long] = None,
+ reviewedAt: Option[Instant] = None,
+ rejectionReason: Option[String] = None
+)
+
+case class PublicationWithAuthor(
+ publication: Publication,
+ authorUsername: String,
+ authorFullName: String,
+ reviewerUsername: Option[String] = None
+)
From a20bbe88e86322034026641bcbf62887e39ec80a Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:27:42 +0000
Subject: [PATCH 19/45] =?UTF-8?q?feat(models):=20agregar=20modelo=20de=20u?=
=?UTF-8?q?suario=20con=20roles,=20estado=20y=20verificaci=C3=B3n=20de=20e?=
=?UTF-8?q?mail?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/models/User.scala | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/models/User.scala b/app/models/User.scala
index 246976e..24b67c7 100644
--- a/app/models/User.scala
+++ b/app/models/User.scala
@@ -11,5 +11,6 @@ case class User(
role: String = "user",
isActive: Boolean = true,
createdAt: Instant = Instant.now(),
- lastLogin: Option[Instant] = None
+ lastLogin: Option[Instant] = None,
+ emailVerified: Boolean = false
)
From d68df74c6c911d44ff4b28abcb2131cd1f39b36d Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:28:12 +0000
Subject: [PATCH 20/45] =?UTF-8?q?feat(repositories):=20implementar=20repos?=
=?UTF-8?q?itorio=20de=20administradores=20con=20Slick=20y=20operaciones?=
=?UTF-8?q?=20de=20autenticaci=C3=B3n?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/repositories/AdminRepository.scala | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/app/repositories/AdminRepository.scala b/app/repositories/AdminRepository.scala
index 44874da..3ca0736 100644
--- a/app/repositories/AdminRepository.scala
+++ b/app/repositories/AdminRepository.scala
@@ -59,6 +59,14 @@ class AdminRepository @Inject()(
db.run(query)
}
+ /**
+ * Actualiza la contraseña de un admin
+ */
+ def updatePassword(id: Long, newPasswordHash: String): Future[Int] = {
+ val query = admins.filter(_.id === id).map(_.passwordHash).update(newPasswordHash)
+ db.run(query)
+ }
+
/**
* Verifica si existe al menos un admin
*/
From a51847a98cf19f97a2687a6926a552e7adb482d0 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:28:46 +0000
Subject: [PATCH 21/45] =?UTF-8?q?feat(repositories):=20implementar=20repos?=
=?UTF-8?q?itorio=20de=20verificaci=C3=B3n=20de=20email=20con=20Slick=20y?=
=?UTF-8?q?=20control=20de=20expiraci=C3=B3n?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../EmailVerificationRepository.scala | 65 +++++++++++++++++++
1 file changed, 65 insertions(+)
create mode 100644 app/repositories/EmailVerificationRepository.scala
diff --git a/app/repositories/EmailVerificationRepository.scala b/app/repositories/EmailVerificationRepository.scala
new file mode 100644
index 0000000..db77d5a
--- /dev/null
+++ b/app/repositories/EmailVerificationRepository.scala
@@ -0,0 +1,65 @@
+package repositories
+
+import javax.inject.{Inject, Singleton}
+import play.api.db.slick.DatabaseConfigProvider
+import slick.jdbc.JdbcProfile
+import models.EmailVerificationCode
+import scala.concurrent.{ExecutionContext, Future}
+import java.time.Instant
+
+@Singleton
+class EmailVerificationRepository @Inject()(dbConfigProvider: DatabaseConfigProvider)(implicit ec: ExecutionContext) {
+ private val dbConfig = dbConfigProvider.get[JdbcProfile]
+
+ import dbConfig._
+ import profile.api._
+
+ private class EmailVerificationCodesTable(tag: Tag) extends Table[EmailVerificationCode](tag, "email_verification_codes") {
+ def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
+ def userId = column[Long]("user_id")
+ def email = column[String]("email")
+ def code = column[String]("code")
+ def createdAt = column[Instant]("created_at")
+ def expiresAt = column[Instant]("expires_at")
+ def verified = column[Boolean]("verified")
+ def attempts = column[Int]("attempts")
+
+ def * = (id.?, userId, email, code, createdAt, expiresAt, verified, attempts) <> ((EmailVerificationCode.apply _).tupled, EmailVerificationCode.unapply)
+ }
+
+ private val codes = TableQuery[EmailVerificationCodesTable]
+
+ def create(code: EmailVerificationCode): Future[EmailVerificationCode] = {
+ val insertQuery = codes returning codes.map(_.id) into ((item, id) => item.copy(id = Some(id)))
+ db.run(insertQuery += code)
+ }
+
+ def findLatestByUserId(userId: Long): Future[Option[EmailVerificationCode]] = {
+ val query = codes
+ .filter(_.userId === userId)
+ .filter(_.verified === false)
+ .sortBy(_.createdAt.desc)
+ .take(1)
+ db.run(query.result.headOption)
+ }
+
+ def verify(id: Long): Future[Int] = {
+ val query = codes.filter(_.id === id).map(_.verified).update(true)
+ db.run(query)
+ }
+
+ def incrementAttempts(id: Long): Future[Int] = {
+ val query = sql"""
+ UPDATE email_verification_codes
+ SET attempts = attempts + 1
+ WHERE id = $id
+ """.asUpdate
+ db.run(query)
+ }
+
+ def deleteExpired(): Future[Int] = {
+ val now = Instant.now()
+ val query = codes.filter(_.expiresAt < now).filter(_.verified === false).delete
+ db.run(query)
+ }
+}
From 7400cf898c040c8c471feab951ddf3668d51831b Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:29:22 +0000
Subject: [PATCH 22/45] =?UTF-8?q?feat(repositories):=20implementar=20repos?=
=?UTF-8?q?itorio=20de=20publicaciones=20con=20Slick,=20moderaci=C3=B3n=20?=
=?UTF-8?q?y=20consultas=20avanzadas?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/repositories/PublicationRepository.scala | 264 +++++++++++++++++++
1 file changed, 264 insertions(+)
create mode 100644 app/repositories/PublicationRepository.scala
diff --git a/app/repositories/PublicationRepository.scala b/app/repositories/PublicationRepository.scala
new file mode 100644
index 0000000..1c24c3d
--- /dev/null
+++ b/app/repositories/PublicationRepository.scala
@@ -0,0 +1,264 @@
+package repositories
+
+import javax.inject.{Inject, Singleton}
+import models.{Publication, PublicationWithAuthor}
+import slick.jdbc.H2Profile.api._
+import java.time.Instant
+import scala.concurrent.{ExecutionContext, Future}
+import play.api.db.slick.DatabaseConfigProvider
+import slick.jdbc.JdbcProfile
+
+/**
+ * Tabla de publicaciones para Slick
+ */
+class PublicationsTable(tag: Tag) extends Table[Publication](tag, "publications") {
+ def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
+ def userId = column[Long]("user_id")
+ def title = column[String]("title")
+ def slug = column[String]("slug")
+ def content = column[String]("content")
+ def excerpt = column[Option[String]]("excerpt")
+ def coverImage = column[Option[String]]("cover_image")
+ def category = column[String]("category")
+ def tags = column[Option[String]]("tags")
+ def status = column[String]("status")
+ def viewCount = column[Int]("view_count")
+ def createdAt = column[Instant]("created_at")
+ def updatedAt = column[Instant]("updated_at")
+ def publishedAt = column[Option[Instant]]("published_at")
+ def reviewedBy = column[Option[Long]]("reviewed_by")
+ def reviewedAt = column[Option[Instant]]("reviewed_at")
+ def rejectionReason = column[Option[String]]("rejection_reason")
+
+ def * = (
+ id.?,
+ userId,
+ title,
+ slug,
+ content,
+ excerpt,
+ coverImage,
+ category,
+ tags,
+ status,
+ viewCount,
+ createdAt,
+ updatedAt,
+ publishedAt,
+ reviewedBy,
+ reviewedAt,
+ rejectionReason
+ ).mapTo[Publication]
+}
+
+@Singleton
+class PublicationRepository @Inject()(
+ dbConfigProvider: DatabaseConfigProvider
+)(implicit ec: ExecutionContext) {
+
+ private val dbConfig = dbConfigProvider.get[JdbcProfile]
+ private val db = dbConfig.db
+ private val publications = TableQuery[PublicationsTable]
+
+ // Implicit GetResult para mapear JOIN con usuarios
+ import slick.jdbc.GetResult
+ implicit val getPublicationWithAuthorResult: GetResult[PublicationWithAuthor] = GetResult { r =>
+ PublicationWithAuthor(
+ publication = Publication(
+ id = Some(r.nextLong()),
+ userId = r.nextLong(),
+ title = r.nextString(),
+ slug = r.nextString(),
+ content = r.nextString(),
+ excerpt = r.nextStringOption(),
+ coverImage = r.nextStringOption(),
+ category = r.nextString(),
+ tags = r.nextStringOption(),
+ status = r.nextString(),
+ viewCount = r.nextInt(),
+ createdAt = r.nextTimestamp().toInstant,
+ updatedAt = r.nextTimestamp().toInstant,
+ publishedAt = r.nextTimestampOption().map(_.toInstant),
+ reviewedBy = r.nextLongOption(),
+ reviewedAt = r.nextTimestampOption().map(_.toInstant),
+ rejectionReason = r.nextStringOption()
+ ),
+ authorUsername = r.nextString(),
+ authorFullName = r.nextString()
+ )
+ }
+
+ /**
+ * Crear una nueva publicación
+ */
+ def create(publication: Publication): Future[Long] = {
+ val insertQuery = publications returning publications.map(_.id)
+ db.run(insertQuery += publication)
+ }
+
+ /**
+ * Actualizar una publicación
+ */
+ def update(publication: Publication): Future[Boolean] = {
+ val query = publications
+ .filter(p => p.id === publication.id && p.userId === publication.userId)
+ .map(p => (p.title, p.slug, p.content, p.excerpt, p.coverImage, p.category, p.tags, p.status, p.updatedAt))
+ .update((
+ publication.title,
+ publication.slug,
+ publication.content,
+ publication.excerpt,
+ publication.coverImage,
+ publication.category,
+ publication.tags,
+ publication.status,
+ Instant.now()
+ ))
+
+ db.run(query).map(_ > 0)
+ }
+
+ /**
+ * Obtener publicación por ID
+ */
+ def findById(id: Long): Future[Option[Publication]] = {
+ db.run(publications.filter(_.id === id).result.headOption)
+ }
+
+ /**
+ * Obtener publicación por slug
+ */
+ def findBySlug(slug: String): Future[Option[Publication]] = {
+ db.run(
+ publications
+ .filter(p => p.slug === slug && p.status === "approved")
+ .result
+ .headOption
+ )
+ }
+
+ /**
+ * Listar publicaciones de un usuario
+ */
+ def findByUserId(userId: Long): Future[List[Publication]] = {
+ db.run(
+ publications
+ .filter(_.userId === userId)
+ .sortBy(_.createdAt.desc)
+ .result
+ ).map(_.toList)
+ }
+
+ /**
+ * Listar todas las publicaciones aprobadas (públicas)
+ */
+ def findAllApproved(limit: Int = 50, offset: Int = 0): Future[List[PublicationWithAuthor]] = {
+ val query = sql"""
+ SELECT p.*, u.username, u.full_name
+ FROM publications p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.status = 'approved'
+ ORDER BY p.published_at DESC
+ LIMIT $limit OFFSET $offset
+ """.as[PublicationWithAuthor]
+
+ db.run(query).map(_.toList)
+ }
+
+ /**
+ * Listar publicaciones pendientes de aprobación
+ */
+ def findPending(limit: Int = 100): Future[List[PublicationWithAuthor]] = {
+ val query = sql"""
+ SELECT p.*, u.username, u.full_name
+ FROM publications p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.status = 'pending'
+ ORDER BY p.updated_at ASC
+ LIMIT $limit
+ """.as[PublicationWithAuthor]
+
+ db.run(query).map(_.toList)
+ }
+
+ /**
+ * Cambiar estado de una publicación (para admin)
+ */
+ def changeStatus(
+ publicationId: Long,
+ newStatus: String,
+ reviewerId: Long,
+ rejectionReason: Option[String] = None
+ ): Future[Boolean] = {
+ val now = Instant.now()
+ val publishedAt = if (newStatus == "approved") Some(now) else None
+
+ val query = publications
+ .filter(_.id === publicationId)
+ .map(p => (p.status, p.reviewedBy, p.reviewedAt, p.publishedAt, p.rejectionReason))
+ .update((newStatus, Some(reviewerId), Some(now), publishedAt, rejectionReason))
+
+ db.run(query).map(_ > 0)
+ }
+
+ /**
+ * Eliminar publicación (solo si es del usuario)
+ */
+ def delete(id: Long, userId: Long): Future[Boolean] = {
+ db.run(
+ publications
+ .filter(p => p.id === id && p.userId === userId)
+ .delete
+ ).map(_ > 0)
+ }
+
+ /**
+ * Incrementar contador de vistas
+ */
+ def incrementViewCount(id: Long): Future[Unit] = {
+ val query = publications
+ .filter(_.id === id)
+ .map(_.viewCount)
+ .result
+ .headOption
+ .flatMap {
+ case Some(count) =>
+ publications
+ .filter(_.id === id)
+ .map(_.viewCount)
+ .update(count + 1)
+ .map(_ => ())
+ case None => DBIO.successful(())
+ }
+
+ db.run(query)
+ }
+
+ /**
+ * Buscar publicaciones por categoría
+ */
+ def findByCategory(category: String, limit: Int = 20): Future[List[PublicationWithAuthor]] = {
+ val query = sql"""
+ SELECT p.*, u.username, u.full_name
+ FROM publications p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.category = $category AND p.status = 'approved'
+ ORDER BY p.published_at DESC
+ LIMIT $limit
+ """.as[PublicationWithAuthor]
+
+ db.run(query).map(_.toList)
+ }
+
+ /**
+ * Obtener estadísticas de publicaciones de un usuario
+ */
+ def getUserStats(userId: Long): Future[Map[String, Int]] = {
+ val query = publications
+ .filter(_.userId === userId)
+ .groupBy(_.status)
+ .map { case (status, group) => (status, group.length) }
+
+ db.run(query.result).map(_.toMap)
+ }
+}
From 69d4d25b3da23b6ea396ef3c133be859ae6fcdac Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:29:49 +0000
Subject: [PATCH 23/45] =?UTF-8?q?feat(repositories):=20implementar=20repos?=
=?UTF-8?q?itorio=20de=20usuarios=20con=20Slick,=20autenticaci=C3=B3n=20y?=
=?UTF-8?q?=20m=C3=A9tricas=20de=20actividad?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/repositories/UserRepository.scala | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/app/repositories/UserRepository.scala b/app/repositories/UserRepository.scala
index 4c75095..2b8a92b 100644
--- a/app/repositories/UserRepository.scala
+++ b/app/repositories/UserRepository.scala
@@ -18,8 +18,9 @@ class UsersTable(tag: Tag) extends Table[User](tag, "users") {
def isActive = column[Boolean]("is_active")
def createdAt = column[Instant]("created_at")
def lastLogin = column[Option[Instant]]("last_login")
+ def emailVerified = column[Boolean]("email_verified")
- def * = (id.?, username, email, passwordHash, fullName, role, isActive, createdAt, lastLogin).mapTo[User]
+ def * = (id.?, username, email, passwordHash, fullName, role, isActive, createdAt, lastLogin, emailVerified).mapTo[User]
}
@Singleton
@@ -146,4 +147,12 @@ class UserRepository @Inject()(
def countNeverLoggedIn(): Future[Int] = {
db.run(users.filter(u => u.lastLogin.isEmpty && u.isActive).length.result)
}
+
+ /**
+ * Actualiza el estado de verificación de email
+ */
+ def updateEmailVerified(id: Long, verified: Boolean): Future[Int] = {
+ val query = users.filter(_.id === id).map(_.emailVerified).update(verified)
+ db.run(query)
+ }
}
From ca1596d02df4a7ecb5ec3fe0b07e402128dd08a9 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:30:14 +0000
Subject: [PATCH 24/45] =?UTF-8?q?feat(services):=20implementar=20servicio?=
=?UTF-8?q?=20de=20env=C3=ADo=20de=20emails=20con=20SMTP,=20verificaci?=
=?UTF-8?q?=C3=B3n=20y=20modo=20desarrollo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/services/EmailService.scala | 212 ++++++++++++++++++++++++++++++++
1 file changed, 212 insertions(+)
create mode 100644 app/services/EmailService.scala
diff --git a/app/services/EmailService.scala b/app/services/EmailService.scala
new file mode 100644
index 0000000..a43118d
--- /dev/null
+++ b/app/services/EmailService.scala
@@ -0,0 +1,212 @@
+package services
+
+import javax.inject.{Inject, Singleton}
+import javax.mail._
+import javax.mail.internet._
+import java.util.Properties
+import play.api.{Configuration, Logger}
+import scala.concurrent.{ExecutionContext, Future}
+import scala.util.{Try, Success, Failure}
+
+@Singleton
+class EmailService @Inject()(
+ config: Configuration
+)(implicit ec: ExecutionContext) {
+
+ private val logger = Logger(this.getClass)
+
+ private val emailEnabled = config.getOptional[Boolean]("email.enabled").getOrElse(false)
+
+ // Configuración SMTP
+ private val smtpHost = config.getOptional[String]("email.smtp.host").getOrElse("smtp.gmail.com")
+ private val smtpPort = config.getOptional[Int]("email.smtp.port").getOrElse(587)
+ private val smtpUser = config.getOptional[String]("email.smtp.user").getOrElse("")
+ private val smtpPassword = config.getOptional[String]("email.smtp.password").getOrElse("")
+ private val fromEmail = config.getOptional[String]("email.from").getOrElse("noreply@reactivemanifesto.com")
+ private val fromName = config.getOptional[String]("email.fromName").getOrElse("Reactive Manifesto")
+
+ /**
+ * Crea una sesión SMTP
+ */
+ private def createSession(): Session = {
+ val props = new Properties()
+ props.put("mail.smtp.auth", "true")
+ props.put("mail.smtp.starttls.enable", "true")
+ props.put("mail.smtp.host", smtpHost)
+ props.put("mail.smtp.port", smtpPort.toString)
+ props.put("mail.smtp.ssl.trust", smtpHost)
+
+ Session.getInstance(props, new Authenticator() {
+ override protected def getPasswordAuthentication(): PasswordAuthentication = {
+ new PasswordAuthentication(smtpUser, smtpPassword)
+ }
+ })
+ }
+
+ /**
+ * Envía un email usando JavaMail
+ */
+ private def sendEmail(to: String, subject: String, htmlBody: String): Try[Unit] = Try {
+ val session = createSession()
+ val message = new MimeMessage(session)
+
+ message.setFrom(new InternetAddress(fromEmail, fromName))
+ message.setRecipients(Message.RecipientType.TO, to)
+ message.setSubject(subject)
+ message.setContent(htmlBody, "text/html; charset=utf-8")
+
+ Transport.send(message)
+ logger.info(s"✅ Email enviado exitosamente a $to")
+ }
+
+ /**
+ * Envía un código de verificación por email
+ */
+ def sendVerificationCode(email: String, code: String, expirationMinutes: Int): Future[Boolean] = Future {
+ if (emailEnabled) {
+ val subject = "Código de Verificación - Reactive Manifesto"
+ val htmlBody = createVerificationEmailHtml(code, expirationMinutes)
+
+ sendEmail(email, subject, htmlBody) match {
+ case Success(_) =>
+ logger.info(s"📧 Código $code enviado a $email")
+ true
+ case Failure(ex) =>
+ logger.error(s"❌ Error enviando email a $email: ${ex.getMessage}", ex)
+ false
+ }
+ } else {
+ // Modo desarrollo: solo log
+ logger.info(s"""
+ |========================================
+ | 📧 CÓDIGO DE VERIFICACIÓN (DEV MODE)
+ |========================================
+ | Email: $email
+ | Código: $code
+ | Expira en: $expirationMinutes minutos
+ |========================================
+ """.stripMargin)
+ true
+ }
+ }
+
+ /**
+ * Envía email de bienvenida
+ */
+ def sendWelcomeEmail(email: String, fullName: String): Future[Boolean] = Future {
+ if (emailEnabled) {
+ val subject = "¡Bienvenido a Reactive Manifesto!"
+ val htmlBody = createWelcomeEmailHtml(fullName)
+
+ sendEmail(email, subject, htmlBody) match {
+ case Success(_) =>
+ logger.info(s"📧 Email de bienvenida enviado a $email")
+ true
+ case Failure(ex) =>
+ logger.error(s"❌ Error enviando email de bienvenida a $email: ${ex.getMessage}", ex)
+ false
+ }
+ } else {
+ logger.info(s"[DEV] Email de bienvenida para $fullName ($email)")
+ true
+ }
+ }
+
+ /**
+ * Crea el HTML del email de verificación
+ */
+ private def createVerificationEmailHtml(code: String, expirationMinutes: Int): String = {
+ s"""
+
+
+
+
+
+
+
+
+
+
+
Hola,
+
Usa el siguiente código para verificar tu cuenta en Reactive Manifesto:
+
+
+
+
+
⏱️ Este código expira en $expirationMinutes minutos
+
Si no solicitaste este código, puedes ignorar este email.
+
Por seguridad, tienes máximo 3 intentos para ingresar el código correcto.
+
+
+
+
+
+
+ """
+ }
+
+ /**
+ * Crea el HTML del email de bienvenida
+ */
+ private def createWelcomeEmailHtml(fullName: String): String = {
+ s"""
+
+
+
+
+
+
+
+
+
+
+
+
👋 Hola, $fullName
+
Tu cuenta ha sido verificada exitosamente
+
+
+
+
Ahora puedes acceder a todo el contenido exclusivo de Reactive Manifesto:
+
+ - 📚 Artículos sobre programación reactiva
+ - 💼 Proyectos del portafolio
+ - 📊 Demos interactivas
+ - 📄 Documentación técnica
+
+
¡Esperamos que disfrutes explorando el mundo de la programación reactiva!
+
+
+
+
+
+
+ """
+ }
+}
From f1441c6545e263a7ca96ec8f6e13151349532b5d Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:30:39 +0000
Subject: [PATCH 25/45] =?UTF-8?q?feat(services):=20implementar=20servicio?=
=?UTF-8?q?=20de=20verificaci=C3=B3n=20de=20email=20con=20generaci=C3=B3n?=
=?UTF-8?q?=20de=20c=C3=B3digos,=20expiraci=C3=B3n=20y=20control=20de=20in?=
=?UTF-8?q?tentos?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/services/EmailVerificationService.scala | 83 +++++++++++++++++++++
1 file changed, 83 insertions(+)
create mode 100644 app/services/EmailVerificationService.scala
diff --git a/app/services/EmailVerificationService.scala b/app/services/EmailVerificationService.scala
new file mode 100644
index 0000000..e1a686c
--- /dev/null
+++ b/app/services/EmailVerificationService.scala
@@ -0,0 +1,83 @@
+package services
+
+import javax.inject.{Inject, Singleton}
+import models.EmailVerificationCode
+import repositories.EmailVerificationRepository
+import scala.concurrent.{ExecutionContext, Future}
+import scala.util.Random
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+@Singleton
+class EmailVerificationService @Inject()(
+ verificationRepository: EmailVerificationRepository,
+ emailService: EmailService
+)(implicit ec: ExecutionContext) {
+
+ private val CODE_LENGTH = 3
+ private val CODE_EXPIRATION_MINUTES = 5
+
+ /**
+ * Genera un código de verificación aleatorio de 3 dígitos
+ */
+ private def generateCode(): String = {
+ val random = new Random()
+ (100 + random.nextInt(900)).toString
+ }
+
+ /**
+ * Crea y envía un código de verificación por email
+ */
+ def createAndSendCode(userId: Long, email: String): Future[EmailVerificationCode] = {
+ val code = generateCode()
+ val expiresAt = Instant.now().plus(CODE_EXPIRATION_MINUTES, ChronoUnit.MINUTES)
+
+ val verificationCode = EmailVerificationCode(
+ userId = userId,
+ email = email,
+ code = code,
+ expiresAt = expiresAt
+ )
+
+ for {
+ created <- verificationRepository.create(verificationCode)
+ _ <- emailService.sendVerificationCode(email, code, CODE_EXPIRATION_MINUTES)
+ } yield created
+ }
+
+ /**
+ * Verifica un código ingresado por el usuario
+ */
+ def verifyCode(userId: Long, inputCode: String): Future[Either[String, Boolean]] = {
+ verificationRepository.findLatestByUserId(userId).flatMap {
+ case None =>
+ Future.successful(Left("No se encontró un código de verificación"))
+
+ case Some(verificationCode) if verificationCode.verified =>
+ Future.successful(Left("Este código ya ha sido utilizado"))
+
+ case Some(verificationCode) if verificationCode.isExpired =>
+ Future.successful(Left("El código ha expirado. Solicita uno nuevo"))
+
+ case Some(verificationCode) if !verificationCode.canAttempt =>
+ Future.successful(Left("Demasiados intentos fallidos. Solicita un nuevo código"))
+
+ case Some(verificationCode) if verificationCode.code != inputCode =>
+ verificationRepository.incrementAttempts(verificationCode.id.get).map { _ =>
+ Left("Código incorrecto. Intenta nuevamente")
+ }
+
+ case Some(verificationCode) =>
+ verificationRepository.verify(verificationCode.id.get).map { _ =>
+ Right(true)
+ }
+ }
+ }
+
+ /**
+ * Limpia códigos expirados
+ */
+ def cleanupExpiredCodes(): Future[Int] = {
+ verificationRepository.deleteExpired()
+ }
+}
From b0e116f13375562cd06c9b3d1faaf0065243dbe7 Mon Sep 17 00:00:00 2001
From: Federico Christian Pfund
<74926730+federicopfund@users.noreply.github.com>
Date: Tue, 13 Jan 2026 03:31:02 +0000
Subject: [PATCH 26/45] =?UTF-8?q?feat(views):=20agregar=20layout=20princip?=
=?UTF-8?q?al=20con=20navegaci=C3=B3n,=20soporte=20de=20sesi=C3=B3n=20y=20?=
=?UTF-8?q?selector=20de=20tema?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/views/main.scala.html | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/app/views/main.scala.html b/app/views/main.scala.html
index 4370b4e..7d02ed5 100644
--- a/app/views/main.scala.html
+++ b/app/views/main.scala.html
@@ -1,4 +1,4 @@
-@(title: String)(content: Html)
+@(title: String)(content: Html)(implicit request: RequestHeader)
@@ -32,7 +32,13 @@ ⚡ Reactive Manifesto
Publicaciones
Portafolio
Contacto
- 🔐 Login
+ @if(request.session.get("userId").isDefined) {
+ 👤 Dashboard
+ 📝 Mis Publicaciones
+ 🚪 Logout
+ } else {
+ 🔐 Login
+ }