Skip to content
Merged
66 changes: 66 additions & 0 deletions src/app/api/hero-video/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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,
// Let the CDN/browser cache it; also allow Next caching
// Note: next.revalidate doesn't affect opaque streams, but it's fine.
next: { revalidate: 60 * 60 * 24 },
});

// Accept normal OK and Partial Content responses
if (!upstream.ok && upstream.status !== 206) {
return new Response("Upstream error", { status: upstream.status });
}

// 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"
);

// Allow range responses to pass through and vary on Range
if (!headers.has("accept-ranges")) headers.set("accept-ranges", "bytes");
headers.append("vary", "range");

return new Response(upstream.body, {
status: upstream.status,
headers,
});
} catch {
return new Response("Video proxy failed", { status: 502 });
}
}
9 changes: 9 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ export default async function RootLayout({
<head>
<meta name="description" content="Mi App en Next.js" />
<title>Capitulo Javeriano ACM</title>
{/* Resource hints to improve background video load */}
<link rel="dns-prefetch" href="//commondatastorage.googleapis.com" />
<link
rel="preconnect"
href="https://commondatastorage.googleapis.com"
crossOrigin="anonymous"
/>
{/* Preload cached proxy video to warm up cache (use fetch for broad support) */}
<link rel="preload" href="/api/hero-video" as="fetch" crossOrigin="anonymous" />
</head>
<body>
<main className="dark:bg-[#121212]">{children}</main>
Expand Down
190 changes: 188 additions & 2 deletions src/components/league/sections/hero.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,192 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { typeText } from "@/utils/typeText";
import { initCodeParticles } from "@/utils/particles";

export function Hero() {
const titleRef = useRef<HTMLHeadingElement | null>(null);
const subtitleRef = useRef<HTMLHeadingElement | null>(null);
const particlesRef = useRef<HTMLDivElement | null>(null);
const heroRef = useRef<HTMLElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(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";

useEffect(() => {
// Try to play video background (fallback to CSS if fails)
const v = videoRef.current;
if (!videoUrl || !v) return;

const onLoaded = () => setVideoVisible(true);
const onError = () => setVideoVisible(false);

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<void>).then === "function") {
(maybePromise as Promise<void>).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);
}, 4000);

return () => {
v.removeEventListener("loadeddata", onLoaded);
v.removeEventListener("error", onError);
window.clearTimeout(readyTimeout);
};
}, [videoUrl]);

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
<div className="h-[50dvh]"></div>
<section ref={heroRef} className="league-hero">
{/* background video layer (fallback to CSS background if hidden) */}
{videoUrl ? (
<div className={`hero-video-bg ${videoVisible ? "is-visible" : ""}`}>
<video
ref={videoRef}
muted
autoPlay
loop
playsInline
preload="auto"
crossOrigin="anonymous"
aria-hidden="true"
>
{/* Try proxy first, then external file as fallback */}
<source src={videoUrl} type="video/mp4" />
<source src={externalFallback} type="video/mp4" />
</video>
</div>
) : null}

{/* dynamic particles layer */}
<div ref={particlesRef} className="code-particles" />

{/* geometric background shapes */}
<div className="geometric-bg">
<div className="geometric-shape shape-1" />
<div className="geometric-shape shape-2" />
<div className="geometric-shape shape-3" />
<div className="geometric-shape shape-4" />
</div>

{/* decorative code snippets */}
<div className="code-snippet code-1">
{"while(true) {"}
<br />
&nbsp;&nbsp;solve();
<br />
&nbsp;&nbsp;compete();
<br />
{"}"}
</div>

<div className="code-snippet code-2">
{"def javeriana():"}
<br />
&nbsp;&nbsp;return &quot;excellence&quot;
</div>

<div className="code-snippet code-3">
{"#include <passion>"}
<br />
{"#include <code>"}
</div>

<div className="hero-content">
<h1 ref={titleRef} className="league-title" />
<h2 ref={subtitleRef} className="league-subtitle" />

<p className="league-text">
Donde los algoritmos cobran vida y la pasión por el código nos une
</p>

<div className="cta-buttons">
<a href="#" className="btn btn--niebla">
🚀 Únete a la Liga
</a>
<a href="#upcoming-events" className="btn btn--niebla">
📊 Ver Competencias
</a>
</div>

{/*TODO: ESTAS STATS SON DE EJEMPLO, CAMBIARLAS DESPUÉS PARA QUE MUESTREN DATOS REALES, PUEDE SER DE LA DB*/}
<div className="stats">
<div className="stat-item">
<span className="stat-number">10+</span>
<span className="stat-label">Años Compitiendo</span>
</div>
{/*<div className="stat-item">
<span className="stat-number">50+</span>
<span className="stat-label">Competencias</span>
</div>
<div className="stat-item">
<span className="stat-number">10+</span>
<span className="stat-label">Años de Historia</span>
</div>*/}
</div>
</div>
</section>
);
}
Loading