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>
This commit is contained in:
BIN
public/video/ira.mp4
Normal file
BIN
public/video/ira.mp4
Normal file
Binary file not shown.
BIN
public/video/nadezda.mp4
Normal file
BIN
public/video/nadezda.mp4
Normal file
Binary file not shown.
BIN
public/video/nastya-2.mp4
Normal file
BIN
public/video/nastya-2.mp4
Normal file
Binary file not shown.
BIN
public/video/nastya.mp4
Normal file
BIN
public/video/nastya.mp4
Normal file
Binary file not shown.
@@ -1,13 +1,231 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField } from "../_components/FormField";
|
import { InputField } from "../_components/FormField";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
|
||||||
|
|
||||||
interface HeroData {
|
interface HeroData {
|
||||||
headline: string;
|
headline: string;
|
||||||
subheadline: string;
|
subheadline: string;
|
||||||
ctaText: string;
|
ctaText: string;
|
||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
|
videos?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOTS = [
|
||||||
|
{ key: "left", label: "Левое", sublabel: "Диагональ слева" },
|
||||||
|
{ key: "center", label: "Центр", sublabel: "Главное видео" },
|
||||||
|
{ key: "right", label: "Правое", sublabel: "Диагональ справа" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function VideoSlot({
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
src,
|
||||||
|
isCenter,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
uploading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
sublabel: string;
|
||||||
|
src: string | null;
|
||||||
|
isCenter: boolean;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
uploading: boolean;
|
||||||
|
}) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-300">{label}</span>
|
||||||
|
{isCenter && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
|
||||||
|
<Smartphone size={10} />
|
||||||
|
мобильная версия
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500">{sublabel}</p>
|
||||||
|
|
||||||
|
{/* Slot */}
|
||||||
|
{src ? (
|
||||||
|
<div className={`group relative overflow-hidden rounded-lg border ${
|
||||||
|
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||||
|
}`}>
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
autoPlay
|
||||||
|
className="aspect-[9/16] w-full object-cover bg-black"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2">
|
||||||
|
<p className="truncate text-xs text-neutral-400">
|
||||||
|
{src.split("/").pop()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isCenter && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
|
||||||
|
<Star size={10} fill="currentColor" />
|
||||||
|
MAIN
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
|
||||||
|
isCenter
|
||||||
|
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
|
||||||
|
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={24} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Upload size={24} />
|
||||||
|
<span className="text-xs font-medium">Загрузить</span>
|
||||||
|
<span className="text-[10px] opacity-60">MP4, до 50МБ</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/mp4,video/webm"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onUpload(file);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoManager({
|
||||||
|
videos,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
videos: string[];
|
||||||
|
onChange: (videos: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [slots, setSlots] = useState<(string | null)[]>(() => [
|
||||||
|
videos[0] || null,
|
||||||
|
videos[1] || null,
|
||||||
|
videos[2] || null,
|
||||||
|
]);
|
||||||
|
const [uploadingIdx, setUploadingIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const syncToParent = useCallback(
|
||||||
|
(updated: (string | null)[]) => {
|
||||||
|
setSlots(updated);
|
||||||
|
// Only propagate when all 3 are filled
|
||||||
|
if (updated.every((s) => s !== null)) {
|
||||||
|
onChange(updated as string[]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleUpload(idx: number, file: File) {
|
||||||
|
setUploadingIdx(idx);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("folder", "hero");
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || "Ошибка загрузки");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { path } = await res.json();
|
||||||
|
const updated = [...slots];
|
||||||
|
updated[idx] = path;
|
||||||
|
syncToParent(updated);
|
||||||
|
} finally {
|
||||||
|
setUploadingIdx(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(idx: number) {
|
||||||
|
const updated = [...slots];
|
||||||
|
updated[idx] = null;
|
||||||
|
setSlots(updated);
|
||||||
|
// Don't propagate incomplete state — keep old saved videos in DB
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFilled = slots.every((s) => s !== null);
|
||||||
|
const filledCount = slots.filter((s) => s !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-neutral-300">
|
||||||
|
Видео на главном экране
|
||||||
|
</label>
|
||||||
|
{!allFilled && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-400">
|
||||||
|
Загружено {filledCount}/3 — загрузите все для сохранения
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{allFilled && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-400">
|
||||||
|
✓ Все видео загружены
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{SLOTS.map((slot, i) => (
|
||||||
|
<VideoSlot
|
||||||
|
key={slot.key}
|
||||||
|
label={slot.label}
|
||||||
|
sublabel={slot.sublabel}
|
||||||
|
src={slots[i]}
|
||||||
|
isCenter={i === 1}
|
||||||
|
uploading={uploadingIdx === i}
|
||||||
|
onUpload={(file) => handleUpload(i, file)}
|
||||||
|
onRemove={() => handleRemove(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 rounded-lg bg-neutral-800/50 p-3 text-xs text-neutral-500">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Monitor size={13} />
|
||||||
|
<span>ПК — диагональный сплит из 3 видео</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Smartphone size={13} />
|
||||||
|
<span>Телефон — только центральное видео</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroEditorPage() {
|
export default function HeroEditorPage() {
|
||||||
@@ -15,6 +233,11 @@ export default function HeroEditorPage() {
|
|||||||
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
|
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<>
|
||||||
|
<VideoManager
|
||||||
|
videos={data.videos || []}
|
||||||
|
onChange={(videos) => update({ ...data, videos })}
|
||||||
|
/>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Заголовок"
|
label="Заголовок"
|
||||||
value={data.headline}
|
value={data.headline}
|
||||||
|
|||||||
@@ -2,38 +2,48 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { writeFile, mkdir } from "fs/promises";
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
||||||
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
||||||
const ALLOWED_FOLDERS = ["team", "master-classes", "news", "classes"];
|
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
||||||
|
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||||
|
const VIDEO_FOLDERS = ["hero"];
|
||||||
|
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
|
||||||
|
const IMAGE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const VIDEO_MAX_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
const rawFolder = (formData.get("folder") as string) || "team";
|
const rawFolder = (formData.get("folder") as string) || "team";
|
||||||
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : "team";
|
const folder = ALL_FOLDERS.includes(rawFolder) ? rawFolder : "team";
|
||||||
|
const isVideoFolder = VIDEO_FOLDERS.includes(folder);
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
const allowedTypes = isVideoFolder ? VIDEO_TYPES : IMAGE_TYPES;
|
||||||
return NextResponse.json(
|
if (!allowedTypes.includes(file.type)) {
|
||||||
{ error: "Only JPEG, PNG, WebP, and AVIF are allowed" },
|
const msg = isVideoFolder
|
||||||
{ status: 400 }
|
? "Only MP4 and WebM videos are allowed"
|
||||||
);
|
: "Only JPEG, PNG, WebP, and AVIF are allowed";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_SIZE) {
|
const maxSize = isVideoFolder ? VIDEO_MAX_SIZE : IMAGE_MAX_SIZE;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const label = isVideoFolder ? "50MB" : "5MB";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "File too large (max 5MB)" },
|
{ error: `File too large (max ${label})` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and sanitize filename
|
// Validate and sanitize filename
|
||||||
const ext = path.extname(file.name).toLowerCase() || ".webp";
|
const ext = path.extname(file.name).toLowerCase() || (isVideoFolder ? ".mp4" : ".webp");
|
||||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
const allowedExts = isVideoFolder ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
|
||||||
|
if (!allowedExts.includes(ext)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid file extension" },
|
{ error: "Invalid file extension" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -47,13 +57,14 @@ export async function POST(request: NextRequest) {
|
|||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
const fileName = `${baseName}-${Date.now()}${ext}`;
|
const fileName = `${baseName}-${Date.now()}${ext}`;
|
||||||
|
|
||||||
const dir = path.join(process.cwd(), "public", "images", folder);
|
const subDir = isVideoFolder ? path.join("video") : path.join("images", folder);
|
||||||
|
const dir = path.join(process.cwd(), "public", subDir);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const filePath = path.join(dir, fileName);
|
const filePath = path.join(dir, fileName);
|
||||||
await writeFile(filePath, buffer);
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
const publicPath = `/images/${folder}/${fileName}`;
|
const publicPath = `/${subDir.replace(/\\/g, "/")}/${fileName}`;
|
||||||
return NextResponse.json({ path: publicPath });
|
return NextResponse.json({ path: publicPath });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
const VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
|
const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
data: SiteContent["hero"];
|
data: SiteContent["hero"];
|
||||||
@@ -15,6 +15,21 @@ interface HeroProps {
|
|||||||
export function Hero({ data: hero }: HeroProps) {
|
export function Hero({ data: hero }: HeroProps) {
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
const scrolledRef = useRef(false);
|
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 scrollToNext = useCallback(() => {
|
||||||
const el = sectionRef.current;
|
const el = sectionRef.current;
|
||||||
@@ -64,45 +79,69 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
||||||
{/* Diagonal split background — 3 dancer videos */}
|
{/* Videos render only after hydration to avoid SSR mismatch */}
|
||||||
<div className="absolute inset-0">
|
{mounted && (
|
||||||
{VIDEOS.map((src, i) => {
|
<>
|
||||||
const positions = [
|
{/* Mobile: single centered video */}
|
||||||
{ left: "0%", width: "38%" },
|
<div className="absolute inset-0 md:hidden">
|
||||||
{ left: "31%", width: "38%" },
|
<video
|
||||||
{ left: "62%", width: "38%" },
|
autoPlay muted loop playsInline preload="auto"
|
||||||
];
|
onCanPlayThrough={handleVideoReady}
|
||||||
const clips = [
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
"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
|
<source src={centerVideo} type="video/mp4" />
|
||||||
autoPlay muted loop playsInline
|
</video>
|
||||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
<div className="absolute inset-0 bg-black/50" />
|
||||||
>
|
</div>
|
||||||
<source src={src} type="video/mp4" />
|
|
||||||
</video>
|
{/* Desktop: diagonal split with all videos */}
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
<div className="absolute inset-0 hidden md:block">
|
||||||
</div>
|
{videos.slice(0, 3).map((src, i) => {
|
||||||
);
|
const positions = [
|
||||||
})}
|
{ left: "0%", width: "38%" },
|
||||||
{/* Gold diagonal lines between panels */}
|
{ left: "31%", width: "38%" },
|
||||||
<svg className="absolute inset-0 h-full w-full z-10 pointer-events-none" preserveAspectRatio="none" viewBox="0 0 100 100">
|
{ left: "62%", width: "38%" },
|
||||||
<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" />
|
const clips = [
|
||||||
</svg>
|
"polygon(0 0, 100% 0, 86% 100%, 0 100%)",
|
||||||
</div>
|
"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 */}
|
{/* Floating hearts */}
|
||||||
<FloatingHearts />
|
<FloatingHearts />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const siteContent: SiteContent = {
|
|||||||
"Открой для себя яркий, завораживающий и незабываемый мир танцев на пилоне!",
|
"Открой для себя яркий, завораживающий и незабываемый мир танцев на пилоне!",
|
||||||
ctaText: "Записаться",
|
ctaText: "Записаться",
|
||||||
ctaHref: "#contact",
|
ctaHref: "#contact",
|
||||||
|
videos: ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"],
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: "О нас",
|
title: "О нас",
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export interface SiteContent {
|
|||||||
subheadline: string;
|
subheadline: string;
|
||||||
ctaText: string;
|
ctaText: string;
|
||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
|
videos?: string[];
|
||||||
};
|
};
|
||||||
team: {
|
team: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user