diff --git a/src/app/api/hero-video/route.ts b/src/app/api/hero-video/route.ts new file mode 100644 index 0000000..18832e4 --- /dev/null +++ b/src/app/api/hero-video/route.ts @@ -0,0 +1,78 @@ +// Proxy route to serve the hero background video with cache and range support +// This improves reliability and allows us to control caching headers from our origin. + +const EXTERNAL_VIDEO_MP4 = + "https://cdn.pixabay.com/video/2022/10/24/136283-764387738_large.mp4"; + +// Support HEAD for preloading checks by the browser/CDN +export async function HEAD(request: Request) { + const res = await GET(request); + return new Response(null, { status: res.status, headers: res.headers }); +} + +export async function GET(request: Request) { + try { + // Grab Range header if present (used for seeking) + const range = request.headers.get("range") ?? undefined; + + const upstream = await fetch(EXTERNAL_VIDEO_MP4, { + // Forward Range requests for seeking/streaming + headers: range ? { Range: range } : undefined, + next: { revalidate: 60 * 60 * 24 }, + }); + + // Accept normal OK and Partial Content responses + if (!upstream.ok && upstream.status !== 206) { + // Error: do not cache this response + return new Response("Upstream error", { + status: upstream.status, + headers: { + "cache-control": "no-store", + vary: "range", + }, + }); + } + + // Copy relevant headers through and set strong caching + const headers = new Headers(); + const copy = [ + "content-type", + "content-length", + "accept-ranges", + "content-range", + "etag", + "last-modified", + ]; + for (const h of copy) { + const v = upstream.headers.get(h); + if (v) headers.set(h, v); + } + + // Fallback content-type if the upstream didn't send it for some reason + if (!headers.has("content-type")) headers.set("content-type", "video/mp4"); + + // Our cache policy (1 day, with SWR) + headers.set( + "cache-control", + "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800" + ); + + // Always allow range responses to pass through and vary on Range + headers.set("accept-ranges", "bytes"); + headers.set("vary", "range"); + + return new Response(upstream.body, { + status: upstream.status, + headers, + }); + } catch { + // Proxy error: do not cache this response + return new Response("Video proxy failed", { + status: 502, + headers: { + "cache-control": "no-store", + vary: "range", + }, + }); + } +} diff --git a/src/components/league/sections/hero.tsx b/src/components/league/sections/hero.tsx index 0cc6ca5..ead4629 100644 --- a/src/components/league/sections/hero.tsx +++ b/src/components/league/sections/hero.tsx @@ -1,6 +1,229 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { typeText } from "@/utils/typeText"; +import { initCodeParticles } from "@/utils/particles"; +import Head from "next/head"; + export function Hero() { + const titleRef = useRef(null); + const subtitleRef = useRef(null); + const particlesRef = useRef(null); + const heroRef = useRef(null); + const videoRef = useRef(null); + const [videoVisible, setVideoVisible] = useState(false); + // Use cached proxy route for the hero video + const videoUrl = "/api/hero-video"; + const externalFallback = + "https://cdn.pixabay.com/video/2022/10/24/136283-764387738_large.mp4"; + const [currentSrc, setCurrentSrc] = useState(videoUrl); + + useEffect(() => { + // Try to play video background (fallback to CSS if fails) + const v = videoRef.current; + if (!currentSrc || !v) return; + + const onLoaded = () => { + setVideoVisible(true); + console.log("Video loaded:", currentSrc); + }; + const onError = () => { + setVideoVisible(false); + console.warn("Video failed to load:", currentSrc); + // If not already using fallback, switch to fallback + if (currentSrc !== externalFallback) { + setCurrentSrc(externalFallback); + } + }; + + v.addEventListener("loadeddata", onLoaded); + v.addEventListener("error", onError); + + // Attempt to play (muted + playsInline should allow autoplay) + try { + const maybePromise = v.play(); + if ( + maybePromise && + typeof (maybePromise as Promise).then === "function" + ) { + (maybePromise as Promise).catch(() => { + // Ignore autoplay rejection; keep showing the first frame + }); + } + } catch { + // Ignore; fallback handled by safety timeout and error event + } + + // Safety timeout: if it doesn't get ready in time, fallback + const readyTimeout = window.setTimeout(() => { + if (v.readyState < 2) { + setVideoVisible(false); + if (currentSrc !== externalFallback) { + setCurrentSrc(externalFallback); + console.warn( + "Video did not become ready, switching to fallback:", + externalFallback, + ); + } + } + }, 4000); + + return () => { + v.removeEventListener("loadeddata", onLoaded); + v.removeEventListener("error", onError); + window.clearTimeout(readyTimeout); + }; + }, [currentSrc]); + + useEffect(() => { + // Toggle helper class on hero when video is visible to soften overlays + const el = heroRef.current; + if (!el) return; + if (videoVisible) el.classList.add("has-video"); + else el.classList.remove("has-video"); + }, [videoVisible]); + + useEffect(() => { + // Partículas ahora usando utilidad reutilizable + const container = particlesRef.current; + if (!container) return; + const cleanup = initCodeParticles(container, { + initialCount: 5, + spawnIntervalMs: 2000, + maxLifetimeMs: 25000, + }); + return cleanup; + }, []); + + useEffect(() => { + // Typing sequence + const titleEl = titleRef.current; + const subtitleEl = subtitleRef.current; + if (!titleEl || !subtitleEl) return; + + let cancelTitle: VoidFunction | undefined; + let cancelSubtitle: VoidFunction | undefined; + const timeouts: number[] = []; + + const startId = window.setTimeout(() => { + cancelTitle = typeText(titleEl, "La Liga", 30, () => { + const id2 = window.setTimeout(() => { + cancelSubtitle = typeText( + subtitleEl, + "Javeriana de Programación", + 30, + ); + }, 300); + timeouts.push(id2); + }); + }, 2000); + timeouts.push(startId); + + return () => { + timeouts.forEach((t) => clearTimeout(t)); + cancelTitle?.(); + cancelSubtitle?.(); + }; + }, []); + return ( - // Por ahora poner algo que ocupe un poco de espacio -
+ <> + + {/* Resource hints to improve background video load */} + + + {/* Preload cached proxy video to warm up cache (use fetch for broad support) */} + + +
+ {/* background video layer (fallback to CSS background if hidden) */} + {currentSrc ? ( +
+ +
+ ) : null} + + {/* dynamic particles layer */} +
+ + {/* geometric background shapes */} +
+
+
+
+
+
+ + {/* decorative code snippets */} +
+ {"while(true) {"} +
+   solve(); +
+   compete(); +
+ {"}"} +
+ +
+ {"def javeriana():"} +
+   return "excellence" +
+ +
+ {"#include "} +
+ {"#include "} +
+ +
+

+

+ +

+ Donde los algoritmos cobran vida y la pasión por el código nos une +

+ + + +
+
+ 10+ + Años Compitiendo +
+
+

+
+ ); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 0f6afa6..a4b3f03 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -8,6 +8,7 @@ @import url("https://use.typekit.net/sxb7wzn.css"); /* Import Flaticon Icons */ @import url("https://cdn-uicons.flaticon.com/3.0.0/uicons-regular-rounded/css/uicons-regular-rounded.css"); +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap"); @tailwind base; @tailwind components; @@ -448,21 +449,27 @@ a:active { text-decoration: none; } -/* Outline button */ -.btn--outline { - background-color: transparent; - /* color: var(--azul-noche); */ - /* border-color: var(--azul-noche); */ - - @apply text-[--azul-noche] border-[--azul-noche] dark:text-white; +/* New: Niebla button (filled with azul niebla) */ +.btn--niebla { + background-color: var(--azul-niebla); + color: var(--azul-noche); + border-color: var(--azul-niebla); } -.btn--outline:hover { - background-color: var(--azul-noche); - color: var(--white); +.btn--niebla:hover { + filter: brightness(0.95); text-decoration: none; } +.dark .btn--niebla { + background-color: var(--azul-niebla); + color: var(--azul-noche); +} + +.dark .btn--niebla:hover { + filter: brightness(0.9); +} + /* Button sizes */ .btn--small { padding: var(--space-sm) var(--space-md); @@ -748,40 +755,255 @@ a:active { } /* =================================== - BRAND-SPECIFIC COMPONENTS + LEAGUE HERO (Liga Javeriana de Programación) =================================== */ -/* Values section */ -.values-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--space-lg); - margin-top: var(--space-2xl); +/* JetBrains Mono for code elements */ + +.league-hero { + min-height: 100vh; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + 135deg, + var(--azul-electrico) 0%, + var(--azul-ultramar) 50%, + var(--azul-electrico) 100% + ); + overflow: hidden; + color: rgb(var(--azul-niebla-rgb)); } -.value-item { +.league-hero::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 20%, rgba(var(--azul-niebla-rgb)/0.05) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(var(--azul-niebla-rgb)/0.05) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(var(--azul-electrico-rgb)/0.05) 0%, transparent 50%), + url('data:image/svg+xml,'); + opacity: 0.8; + animation: float 20s ease-in-out infinite; +} + +.league-hero::after { + content: ""; + position: absolute; + inset: 0; + background: url('data:image/svg+xml,'); + opacity: 0.6; + animation: float 25s ease-in-out infinite reverse; + pointer-events: none; +} + +/* When video is visible, soften the overlay layers */ +.league-hero.has-video::before { opacity: 0.35; } +.league-hero.has-video::after { opacity: 0.2; } + +/* Floating code particles layer */ +.code-particles { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; +} + +/* Geometric background shapes */ +.geometric-bg { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; +} + +.geometric-shape { + position: absolute; + border: 1px solid rgba(var(--azul-niebla-rgb)/0.05); + animation: geometricFloat 30s linear infinite; +} + +.shape-1 { + top: 10%; + left: 5%; + width: 200px; + height: 200px; + border-radius: 50%; + animation-duration: 40s; +} + +.shape-2 { + top: 60%; + right: 10%; + width: 150px; + height: 150px; + transform: rotate(45deg); + animation-duration: 35s; + animation-direction: reverse; +} + +.shape-3 { + bottom: 20%; + left: 15%; + width: 100px; + height: 100px; + border-radius: 20px; + animation-duration: 50s; +} + +.shape-4 { + top: 30%; + right: 25%; + width: 80px; + height: 80px; + border-radius: 50%; + border-color: rgba(var(--azul-electrico-rgb)/0.08); + animation-duration: 45s; + animation-direction: reverse; +} + +/* Individual particle */ +.particle { + position: absolute; + font-family: "JetBrains Mono", monospace; + font-size: 12px; + color: rgba(var(--azul-niebla-rgb)/0.3); + animation: codeFloat 15s linear infinite; +} + +/* Content */ +.hero-content { text-align: center; - padding: var(--space-lg); - background-color: var(--azul-niebla); - border-radius: var(--radius-lg); - transition: transform var(--transition-normal); + z-index: 10; + max-width: 900px; + padding: 0 var(--space-md); + position: relative; } -.value-item:hover { - transform: translateY(-2px); +.league-title { + font-size: 10rem; + font-weight: var(--font-black); + margin-bottom: var(--space-md); + font-family: var(--font-primary); + font-style: italic; + background: var(--azul-niebla); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.1; + animation: slideInUp 1s ease-out 0.3s both; } -.value-item__title { +.league-subtitle { + font-size: 3rem; + font-weight: var(--font-black); + margin-bottom: var(--space-md); font-family: var(--font-primary); - font-size: var(--font-size-lg); - font-weight: var(--font-bold); - color: var(--azul-electrico); - margin-bottom: var(--space-sm); + font-style: italic; + background: var(--azul-niebla); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.1; + animation: slideInUp 1s ease-out 0.3s both; + position: relative; +} + +.typing-cursor { + display: inline-block; + width: 1ch; + animation: blink 1s steps(1) infinite; } /* =================================== RESPONSIVE DESIGN =================================== */ +.league-text { + font-size: 1.5rem; + font-weight: var(--font-regular); + font-family: var(--font-secondary); + color: rgba(var(--azul-niebla-rgb)/0.8); + margin-bottom: var(--space-2xl); + animation: slideInUp 1s ease-out 0.6s both; +} + +.cta-buttons { + display: flex; + gap: var(--space-lg); + justify-content: center; + flex-wrap: wrap; + margin-bottom: var(--space-3xl); + animation: slideInUp 1s ease-out 0.9s both; +} + +/* Stats */ +.stats { + display: flex; + justify-content: center; + gap: 60px; + flex-wrap: wrap; + animation: slideInUp 1s ease-out 1.2s both; +} + +.stat-item { text-align: center; } + +.stat-number { + font-size: 2.5rem; + font-weight: var(--font-black); + color: var(--azul-niebla); + display: block; + font-family: "JetBrains Mono", monospace; +} + +.stat-label { + font-size: 0.9rem; + color: rgba(var(--azul-niebla-rgb)/0.7); + margin-top: 5px; +} + +/* Decorative code snippets */ +.code-snippet { + position: absolute; + font-family: "JetBrains Mono", monospace; + font-size: 14px; + color: rgba(var(--azul-niebla-rgb)/0.4); + pointer-events: none; + z-index: 2; +} + +.code-1 { top: 15%; left: 10%; animation: float 8s ease-in-out infinite; } +.code-2 { top: 70%; right: 15%; animation: float 10s ease-in-out infinite reverse; } +.code-3 { bottom: 20%; left: 5%; animation: float 12s ease-in-out infinite; } + +/* Animations */ +@keyframes slideInUp { + from { opacity: 0; transform: translateY(30px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes float { + 0%,100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-20px) rotate(5deg); } +} + +@keyframes geometricFloat { + 0% { transform: translateY(0) rotate(0deg) scale(1); opacity: 0.05; } + 25% { transform: translateY(-30px) rotate(90deg) scale(1.1); opacity: 0.08; } + 50% { transform: translateY(-15px) rotate(180deg) scale(0.9); opacity: 0.03; } + 75% { transform: translateY(-25px) rotate(270deg) scale(1.05); opacity: 0.06; } + 100% { transform: translateY(0) rotate(360deg) scale(1); opacity: 0.05; } +} + +@keyframes codeFloat { + 0% { transform: translateY(100vh) rotate(0deg); opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { transform: translateY(-100px) rotate(360deg); opacity: 0; } +} + +/* Responsive */ @media (max-width: 768px) { :root { --font-size-5xl: 2.5rem; @@ -814,6 +1036,20 @@ a:active { .flex-row { flex-direction: column; } + + /* Ajustes solicitados: títulos más grandes, subtítulo descriptivo más pequeño, botones más compactos */ + .league-title { font-size: 3rem; } + .league-subtitle { font-size: 2rem; } + .league-text { font-size: 1.05rem; line-height: 1.35; } + .stats { gap: 40px; } + .cta-buttons { flex-direction: column; align-items: center; } + .cta-buttons .btn { + width: 160px; + justify-content: center; + padding: var(--space-sm) var(--space-lg); + font-size: 0.9rem; + border-radius: var(--radius-lg); + } } @media (max-width: 480px) { @@ -907,3 +1143,41 @@ a:active { ::-webkit-scrollbar-thumb:hover { background-color: var(--azul-crayon); } + +/* =================================== + HERO VIDEO BACKGROUND + =================================== */ + +/* League hero video background layer */ +.hero-video-bg { + position: absolute; + inset: 0; + z-index: 0; /* below particles/shapes and content, above section background */ + opacity: 0; + transition: opacity 600ms ease; + pointer-events: none; /* keep UI interactive */ + /* Adjustable dim intensity (0 to 1) */ + --video-dim: 0.35; +} + +.hero-video-bg.is-visible { opacity: 1; } + +.hero-video-bg video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + z-index: 0; +} + +/* Dim overlay on top of the video to reduce exposure */ +.hero-video-bg::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, var(--video-dim)); + z-index: 1; +} diff --git a/src/utils/particles.ts b/src/utils/particles.ts new file mode 100644 index 0000000..cb8803c --- /dev/null +++ b/src/utils/particles.ts @@ -0,0 +1,94 @@ +// Utility to initialize floating code particles in a container +// Returns a cleanup function that stops spawning and removes existing particles. + +export interface InitCodeParticlesOptions { + initialCount?: number; // how many particles to schedule initially + spawnIntervalMs?: number; // interval between spawns + maxLifetimeMs?: number; // lifetime before removal + codeElements?: string[]; // custom code tokens + minAnimDurationSec?: number; + maxExtraAnimDurationSec?: number; // random additional duration + minAnimDelaySec?: number; + maxAnimDelaySec?: number; +} + +const DEFAULT_CODE_ELEMENTS = [ + "for()", + "while()", + "if()", + "class", + "function", + "return", + "var", + "let", + "const", + "{}", + "[]", + "()", + "=>", + "==", + "!=", + "++", + "--", + "&&", + "||", + "int", + "string", + "bool", + "array", + "list", + "dict", + "map", +]; + +export function initCodeParticles( + container: HTMLElement, + { + initialCount = 5, + spawnIntervalMs = 2000, + maxLifetimeMs = 25000, + codeElements = DEFAULT_CODE_ELEMENTS, + minAnimDurationSec = 10, + maxExtraAnimDurationSec = 10, + minAnimDelaySec = 0, + maxAnimDelaySec = 2, + }: InitCodeParticlesOptions = {} +): () => void { + const particles: HTMLElement[] = []; + const timeouts: number[] = []; + + const addParticle = () => { + const p = document.createElement("div"); + p.className = "particle"; + p.textContent = codeElements[Math.floor(Math.random() * codeElements.length)]; + p.style.left = Math.random() * 100 + "vw"; + const delay = Math.random() * (maxAnimDelaySec - minAnimDelaySec) + minAnimDelaySec; + const dur = + Math.random() * maxExtraAnimDurationSec + minAnimDurationSec; + p.style.animationDelay = delay + "s"; + p.style.animationDuration = dur + "s"; + + container.appendChild(p); + particles.push(p); + + const removalTimeout = window.setTimeout(() => { + if (p.parentNode) p.parentNode.removeChild(p); + }, maxLifetimeMs); + timeouts.push(removalTimeout); + }; + + // schedule initial particles with slight staggering + for (let i = 0; i < initialCount; i++) { + const t = window.setTimeout(addParticle, i * 500); + timeouts.push(t); + } + + const intervalId = window.setInterval(addParticle, spawnIntervalMs); + + // Cleanup + return () => { + window.clearInterval(intervalId); + timeouts.forEach((t) => window.clearTimeout(t)); + particles.forEach((p) => p.remove()); + }; +} diff --git a/src/utils/typeText.ts b/src/utils/typeText.ts new file mode 100644 index 0000000..3e152d5 --- /dev/null +++ b/src/utils/typeText.ts @@ -0,0 +1,40 @@ +export type CancelTypeText = () => void; + +export function typeText( + el: HTMLElement, + text: string, + speed = 30, + onDone?: () => void +): CancelTypeText { + let i = 0; + let destroyed = false; + const timeouts: number[] = []; + + el.innerHTML = ""; + const cursor = document.createElement("span"); + cursor.className = "typing-cursor"; + cursor.textContent = "|"; + el.appendChild(cursor); + + const tick = () => { + if (destroyed) return; + if (i < text.length) { + cursor.before(document.createTextNode(text[i])); + i++; + const id = window.setTimeout(tick, speed); + timeouts.push(id); + } else { + cursor.remove(); + onDone?.(); + } + }; + + tick(); + + return () => { + destroyed = true; + timeouts.forEach((t) => clearTimeout(t)); + if (cursor.isConnected) cursor.remove(); + }; +} +