sadar / frontend /src /pages /Presentation.tsx
Ruperth's picture
feat: add team member names and fix spanish tower controller role
cb75520
Raw
History Blame Contribute Delete
11.5 kB
import { motion, useScroll, useTransform, type MotionValue } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import LangToggle from "../components/LangToggle";
import { useT } from "../i18n";
import "./presentation.css";
type Progress = MotionValue<number>;
type Range = [number, number];
const TEAM = [
{ roleKey: "tower1", name: "Txema Puch" },
{ roleKey: "pilot", name: "Mónica Gómez" },
{ roleKey: "tower2", name: "Roberto Molero" },
] as const;
const MOBILE_BREAKPOINT_QUERY = "(max-width: 900px)";
function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia(MOBILE_BREAKPOINT_QUERY);
const update = () => setIsMobile(mql.matches);
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);
return isMobile;
}
function useReveal(progress: Progress, range: Range, first = false): MotionValue<number> {
const [start, end] = range;
const pad = Math.min(0.04, (end - start) / 3);
if (first) {
return useTransform(progress, [start, end - pad, end], [1, 1, 0]);
}
return useTransform(progress, [start, start + pad, end - pad, end], [0, 1, 1, 0]);
}
function ImageScene({
progress,
range,
image,
kicker,
title,
sub,
first,
}: {
progress: Progress;
range: Range;
image: string;
kicker: string;
title: string;
sub?: string;
first?: boolean;
}) {
const opacity = useReveal(progress, range, first);
return (
<motion.div className="scene" style={{ opacity, backgroundImage: `url(/presentation/${image})` }}>
<div className="scene__veil" />
<div className="scene__text">
<div className="scene__kicker">{kicker}</div>
<h2 className="scene__title">{title}</h2>
{sub && <p className="scene__sub">{sub}</p>}
</div>
</motion.div>
);
}
function WindowScene({
progress,
range,
closing,
kicker,
title,
}: {
progress: Progress;
range: Range;
closing: boolean;
kicker: string;
title: string;
}) {
const opacity = useReveal(progress, range);
const shadeFrom: Range = [range[0] + 0.02, range[0] + 0.07];
const shadeY = useTransform(progress, shadeFrom, closing ? ["-100%", "0%"] : ["0%", "-100%"]);
return (
<motion.div className="scene" style={{ opacity, backgroundImage: "url(/presentation/window.jpg)" }}>
<div className="scene__veil" />
<motion.div className="scene__shade" style={{ y: shadeY }} />
<div className="scene__text">
<div className="scene__kicker">{kicker}</div>
<h2 className="scene__title">{title}</h2>
</div>
</motion.div>
);
}
function ExplainPanel({
progress,
range,
title,
body,
}: {
progress: Progress;
range: Range;
title: string;
body: string;
}) {
const opacity = useReveal(progress, range);
const y = useTransform(progress, range, [28, -28]);
return (
<motion.div className="explain__panel" style={{ opacity, y }}>
<h3>{title}</h3>
<p>{body}</p>
</motion.div>
);
}
function Explain({
progress,
range,
panelRanges,
header,
panels,
}: {
progress: Progress;
range: Range;
panelRanges: Range[];
header: string;
panels: { title: string; body: string }[];
}) {
const opacity = useReveal(progress, range);
return (
<motion.div className="scene" style={{ opacity, backgroundImage: "url(/presentation/console.jpg)" }}>
<div className="scene__veil" />
<div className="explain__header">{header}</div>
<div className="explain">
{panels.map((panel, index) => (
<ExplainPanel
key={index}
progress={progress}
range={panelRanges[index]}
title={panel.title}
body={panel.body}
/>
))}
</div>
</motion.div>
);
}
const INTRO_PANEL_RANGES: Range[] = [
[0.51, 0.62],
[0.62, 0.74],
[0.74, 0.86],
];
export default function Presentation() {
const t = useT();
const s = t.scenes;
const trackRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const handedOffRef = useRef(false);
const { scrollYProgress } = useScroll({
target: trackRef,
offset: ["start start", "end end"],
});
useEffect(() => {
const unsub = scrollYProgress.on("change", (value) => {
if (value > 0.96 && !handedOffRef.current) {
handedOffRef.current = true;
navigate("/dashboard");
}
});
return () => unsub();
}, [scrollYProgress, navigate]);
return (
<div className="presentation">
<motion.div
className="presentation__progress"
style={{ scaleX: scrollYProgress, width: "100%" }}
/>
<div className="presentation__skip" style={{ display: "flex", gap: 8 }}>
<LangToggle />
<Link to="/dashboard">
<button>{s.skip}</button>
</Link>
</div>
<div className="presentation__track" ref={trackRef}>
<div className="stage">
<ImageScene
progress={scrollYProgress}
range={[0, 0.14]}
first
image="terminal.jpg"
kicker={s.terminal.kicker}
title={s.terminal.title}
sub={s.terminal.sub}
/>
<ImageScene
progress={scrollYProgress}
range={[0.13, 0.28]}
image="jetbridge.jpg"
kicker={s.jetbridge.kicker}
title={s.jetbridge.title}
/>
<ImageScene
progress={scrollYProgress}
range={[0.27, 0.42]}
image="cabin.jpg"
kicker={s.cabin.kicker}
title={s.cabin.title}
sub={s.cabin.sub}
/>
<WindowScene
progress={scrollYProgress}
range={[0.41, 0.52]}
closing
kicker={s.closing.kicker}
title={s.closing.title}
/>
<Explain
progress={scrollYProgress}
range={[0.5, 0.92]}
panelRanges={INTRO_PANEL_RANGES}
header={s.explainHeader}
panels={s.panels}
/>
</div>
</div>
</div>
);
}
export function PresentationFinal() {
const t = useT();
const s = t.scenes;
const trackRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: trackRef,
offset: ["start start", "end end"],
});
const [creditsVisible, setCreditsVisible] = useState(false);
useEffect(() => {
const unsub = scrollYProgress.on("change", (value) => {
setCreditsVisible(value > 0.88);
});
return () => unsub();
}, [scrollYProgress]);
return (
<div className="presentation">
<motion.div
className="presentation__progress"
style={{ scaleX: scrollYProgress, width: "100%" }}
/>
<div className="presentation__skip" style={{ display: "flex", gap: 8 }}>
<LangToggle />
<Link to="/dashboard">
<button>{s.skip}</button>
</Link>
</div>
<div className="presentation__track" ref={trackRef}>
<div className="stage">
<WindowScene
progress={scrollYProgress}
range={[0, 0.34]}
closing={false}
kicker={s.opening.kicker}
title={s.opening.title}
/>
<ImageScene
progress={scrollYProgress}
range={[0.33, 0.66]}
image="leaving.jpg"
kicker={s.leaving.kicker}
title={s.leaving.title}
/>
<Credits
progress={scrollYProgress}
range={[0.65, 1.0]}
roles={s.ack.roles}
teamHeader={s.ack.teamHeader}
thanks={s.ack.thanks}
subtitle={s.ack.subtitle}
visible={creditsVisible}
/>
</div>
</div>
</div>
);
}
function Credits({
progress,
range,
roles,
teamHeader,
thanks,
subtitle,
visible,
}: {
progress: Progress;
range: Range;
roles: { tower1: string; pilot: string; tower2: string };
teamHeader: string;
thanks: string;
subtitle: string;
visible: boolean;
}) {
const [start, end] = range;
const pad = Math.min(0.04, (end - start) / 3);
const opacity = useTransform(progress, [start, start + pad], [0, 1]);
const textOpacity = useTransform(progress, [start + 0.03, start + 0.1], [0, 1]);
const isMobile = useIsMobile();
const roleFontSize = isMobile ? 11 : 12;
const nameFontSize = isMobile ? 18 : 22;
const teamBlockGap = isMobile ? 16 : 22;
const logoWidth = isMobile ? "min(440px, 85vw)" : "min(440px, 70vw)";
return (
<motion.div
className="scene"
style={{ opacity, backgroundImage: "url(/presentation/acknowledgements.jpg)" }}
>
<div className="scene__veil" />
<motion.div
className="ack"
style={{
opacity: textOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 18,
textAlign: "center",
padding: "24px",
}}
>
<img
src="/sadar-logo.png"
alt="SADAR"
className="radar-pulse"
style={{ width: logoWidth, height: "auto", display: "block" }}
/>
<p className="scene__sub" style={{ marginTop: 0 }}>{subtitle}</p>
<div
style={{
marginTop: 24,
fontFamily: "var(--mono)",
fontSize: 13,
letterSpacing: "0.32em",
textTransform: "uppercase",
color: "#ffffff",
textShadow: "0 1px 6px rgba(0,0,0,0.85)",
}}
>
{teamHeader}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: teamBlockGap,
marginTop: 10,
opacity: visible ? 1 : 0,
transition: "opacity 600ms ease",
}}
>
{TEAM.map((member, idx) => (
<div key={idx} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div
style={{
fontFamily: "var(--mono)",
fontSize: roleFontSize,
letterSpacing: "0.22em",
textTransform: "uppercase",
color: "#7fd1c6",
textShadow: "0 1px 5px rgba(0,0,0,0.85)",
}}
>
{roles[member.roleKey]}
</div>
<div
className="radar-name-pulse"
style={{
fontFamily: "var(--mono)",
fontSize: nameFontSize,
letterSpacing: "0.1em",
color: "#ffffff",
animationDelay: `${idx * 0.4}s`,
}}
>
{member.name}
</div>
</div>
))}
</div>
<div
style={{
marginTop: 32,
fontFamily: "var(--mono)",
fontSize: 16,
letterSpacing: "0.2em",
color: "#7fd1c6",
textTransform: "uppercase",
textShadow: "0 1px 6px rgba(0,0,0,0.85)",
}}
>
{thanks}
</div>
</motion.div>
</motion.div>
);
}