Files
blackheart-website/src/components/sections/Hero.tsx
diana.dolgolyova c3cbd90fe4 feat: hero video management — diagonal split, mobile single video, admin editor
- Hero: diagonal 3-video split on desktop, single center video on mobile (CSS breakpoint)
- Videos render client-side only to avoid hydration mismatch
- Loading overlay (z-5) hides buffering without blocking text content
- Admin hero editor: 3 fixed slots (left/center/right) with upload, preview, remove
- Center slot marked as main (used on mobile), save blocked until all 3 filled
- Upload API: supports MP4/WebM (50MB) in hero folder alongside existing image uploads
- Added videos field to hero type and fallback data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:24:29 +03:00

178 lines
6.7 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { HeroLogo } from "@/components/ui/HeroLogo";
import type { SiteContent } from "@/types/content";
const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
interface HeroProps {
data: SiteContent["hero"];
}
export function Hero({ data: hero }: HeroProps) {
const sectionRef = useRef<HTMLElement>(null);
const scrolledRef = useRef(false);
const overlayRef = useRef<HTMLDivElement>(null);
const readyCount = useRef(0);
const [mounted, setMounted] = useState(false);
const videos = hero.videos?.length ? hero.videos : DEFAULT_VIDEOS;
const centerVideo = videos[Math.floor(videos.length / 2)] || videos[0];
const totalVideos = videos.slice(0, 3).length + 1; // desktop (3) + mobile (1)
useEffect(() => { setMounted(true); }, []);
const handleVideoReady = useCallback(() => {
readyCount.current += 1;
if (readyCount.current >= totalVideos && overlayRef.current) {
overlayRef.current.style.opacity = "0";
}
}, [totalVideos]);
const scrollToNext = useCallback(() => {
const el = sectionRef.current;
if (!el) return;
let next = el.nextElementSibling;
while (next && next.tagName !== "SECTION") {
next = next.nextElementSibling;
}
next?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
function handleWheel(e: WheelEvent) {
if (e.deltaY <= 0 || scrolledRef.current) return;
if (window.scrollY > 10) return;
scrolledRef.current = true;
scrollToNext();
setTimeout(() => { scrolledRef.current = false; }, 1000);
}
function handleTouchStart(e: TouchEvent) {
(el as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
}
function handleTouchEnd(e: TouchEvent) {
const startY = Number((el as HTMLElement).dataset.touchY);
const endY = e.changedTouches[0].clientY;
if (startY - endY > 50 && !scrolledRef.current && window.scrollY < 10) {
scrolledRef.current = true;
scrollToNext();
setTimeout(() => { scrolledRef.current = false; }, 1000);
}
}
el.addEventListener("wheel", handleWheel, { passive: true });
el.addEventListener("touchstart", handleTouchStart, { passive: true });
el.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => {
el.removeEventListener("wheel", handleWheel);
el.removeEventListener("touchstart", handleTouchStart);
el.removeEventListener("touchend", handleTouchEnd);
};
}, [scrollToNext]);
return (
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
{/* Videos render only after hydration to avoid SSR mismatch */}
{mounted && (
<>
{/* Mobile: single centered video */}
<div className="absolute inset-0 md:hidden">
<video
autoPlay muted loop playsInline preload="auto"
onCanPlayThrough={handleVideoReady}
className="absolute inset-0 h-full w-full object-cover object-center"
>
<source src={centerVideo} type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/50" />
</div>
{/* Desktop: diagonal split with all videos */}
<div className="absolute inset-0 hidden md:block">
{videos.slice(0, 3).map((src, i) => {
const positions = [
{ left: "0%", width: "38%" },
{ left: "31%", width: "38%" },
{ left: "62%", width: "38%" },
];
const clips = [
"polygon(0 0, 100% 0, 86% 100%, 0 100%)",
"polygon(14% 0, 100% 0, 86% 100%, 0 100%)",
"polygon(14% 0, 100% 0, 100% 100%, 0 100%)",
];
return (
<div
key={src}
className="absolute top-0 bottom-0 overflow-hidden"
style={{
left: positions[i].left,
width: positions[i].width,
clipPath: clips[i],
}}
>
<video
autoPlay muted loop playsInline preload="auto"
onCanPlayThrough={handleVideoReady}
className="absolute inset-0 h-full w-full object-cover object-center"
>
<source src={src} type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/50" />
</div>
);
})}
{/* Gold diagonal lines between panels */}
<svg className="absolute inset-0 h-full w-full z-10 pointer-events-none" preserveAspectRatio="none" viewBox="0 0 100 100">
<line x1="38" y1="0" x2="33" y2="100" stroke="rgba(201,169,110,0.25)" strokeWidth="0.15" />
<line x1="69" y1="0" x2="64" y2="100" stroke="rgba(201,169,110,0.25)" strokeWidth="0.15" />
</svg>
</div>
</>
)}
{/* Loading overlay — covers videos but not content */}
<div
ref={overlayRef}
className="absolute inset-0 z-[5] bg-[#050505] pointer-events-none transition-opacity duration-1000"
/>
{/* Floating hearts */}
<FloatingHearts />
{/* Content */}
<div className="section-container relative z-10 text-center">
<div className="hero-logo relative mx-auto mb-10 flex items-center justify-center" style={{ width: 220, height: 181 }}>
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
<div className="hero-logo-heartbeat relative">
<HeroLogo
size={220}
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)]"
/>
</div>
</div>
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
<span className="gradient-text">{hero.headline}</span>
</h1>
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-[#b8a080] sm:text-xl">
{hero.subheadline}
</p>
<div className="hero-cta mt-12">
<Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}>
{hero.ctaText}
</Button>
</div>
</div>
</section>
);
}