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:
2026-03-23 16:24:29 +03:00
parent 1c6462d340
commit c3cbd90fe4
9 changed files with 331 additions and 56 deletions

BIN
public/video/ira.mp4 Normal file

Binary file not shown.

BIN
public/video/nadezda.mp4 Normal file

Binary file not shown.

BIN
public/video/nastya-2.mp4 Normal file

Binary file not shown.

BIN
public/video/nastya.mp4 Normal file

Binary file not shown.

View File

@@ -1,13 +1,231 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
interface HeroData {
headline: string;
subheadline: string;
ctaText: 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() {
@@ -15,6 +233,11 @@ export default function HeroEditorPage() {
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
{(data, update) => (
<>
<VideoManager
videos={data.videos || []}
onChange={(videos) => update({ ...data, videos })}
/>
<InputField
label="Заголовок"
value={data.headline}

View File

@@ -2,38 +2,48 @@ import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
const ALLOWED_FOLDERS = ["team", "master-classes", "news", "classes"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const VIDEO_TYPES = ["video/mp4", "video/webm"];
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
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) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
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) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Only JPEG, PNG, WebP, and AVIF are allowed" },
{ status: 400 }
);
const allowedTypes = isVideoFolder ? VIDEO_TYPES : IMAGE_TYPES;
if (!allowedTypes.includes(file.type)) {
const msg = isVideoFolder
? "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(
{ error: "File too large (max 5MB)" },
{ error: `File too large (max ${label})` },
{ status: 400 }
);
}
// Validate and sanitize filename
const ext = path.extname(file.name).toLowerCase() || ".webp";
if (!ALLOWED_EXTENSIONS.includes(ext)) {
const ext = path.extname(file.name).toLowerCase() || (isVideoFolder ? ".mp4" : ".webp");
const allowedExts = isVideoFolder ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
if (!allowedExts.includes(ext)) {
return NextResponse.json(
{ error: "Invalid file extension" },
{ status: 400 }
@@ -47,13 +57,14 @@ export async function POST(request: NextRequest) {
.slice(0, 50);
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 });
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dir, fileName);
await writeFile(filePath, buffer);
const publicPath = `/images/${folder}/${fileName}`;
const publicPath = `/${subDir.replace(/\\/g, "/")}/${fileName}`;
return NextResponse.json({ path: publicPath });
}

View File

@@ -1,12 +1,12 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
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 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 {
data: SiteContent["hero"];
@@ -15,6 +15,21 @@ interface HeroProps {
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;
@@ -64,45 +79,69 @@ export function Hero({ data: hero }: HeroProps) {
return (
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
{/* Diagonal split background — 3 dancer videos */}
<div className="absolute inset-0">
{VIDEOS.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],
}}
{/* 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"
>
<video
autoPlay muted loop playsInline
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>
<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 />

View File

@@ -12,6 +12,7 @@ export const siteContent: SiteContent = {
"Открой для себя яркий, завораживающий и незабываемый мир танцев на пилоне!",
ctaText: "Записаться",
ctaHref: "#contact",
videos: ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"],
},
about: {
title: "О нас",

View File

@@ -116,6 +116,7 @@ export interface SiteContent {
subheadline: string;
ctaText: string;
ctaHref: string;
videos?: string[];
};
team: {
title: string;