@publication.title
+ + + + @if(publication.excerpt.isDefined) { +@publication.excerpt.get
+")) +
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; 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; + } + } +} 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%); } +} 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; + } + } +} 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); + } + } +} 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); + } +} 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'; 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 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") + ) + } + } } 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] => 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" + )) + } + } + } +} 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()}" + } +} 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") + ) + } + } +} 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 +} 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 +) 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 ) 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 */ 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) + } +} 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) + } +} 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) + } } 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.
+Tu cuenta ha sido verificada exitosamente
+Ahora puedes acceder a todo el contenido exclusivo de Reactive Manifesto:
+¡Esperamos que disfrutes explorando el mundo de la programación reactiva!
+Panel de Administración
-