diff --git a/public/video/ira.mp4 b/public/video/ira.mp4 new file mode 100644 index 0000000..56c41d6 Binary files /dev/null and b/public/video/ira.mp4 differ diff --git a/public/video/nadezda.mp4 b/public/video/nadezda.mp4 new file mode 100644 index 0000000..84f92d6 Binary files /dev/null and b/public/video/nadezda.mp4 differ diff --git a/public/video/nastya-2.mp4 b/public/video/nastya-2.mp4 new file mode 100644 index 0000000..cf0e4ce Binary files /dev/null and b/public/video/nastya-2.mp4 differ diff --git a/public/video/nastya.mp4 b/public/video/nastya.mp4 new file mode 100644 index 0000000..0a912b4 Binary files /dev/null and b/public/video/nastya.mp4 differ diff --git a/src/app/admin/hero/page.tsx b/src/app/admin/hero/page.tsx index 8db8b8f..956f04a 100644 --- a/src/app/admin/hero/page.tsx +++ b/src/app/admin/hero/page.tsx @@ -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(null); + + return ( +
+ {/* Label */} +
+ {label} + {isCenter && ( + + + мобильная версия + + )} +
+

{sublabel}

+ + {/* Slot */} + {src ? ( +
+
+ ) : ( + + )} + + { + const file = e.target.files?.[0]; + if (file) onUpload(file); + if (fileRef.current) fileRef.current.value = ""; + }} + /> +
+ ); +} + +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(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 ( +
+
+ + {!allFilled && ( + + Загружено {filledCount}/3 — загрузите все для сохранения + + )} + {allFilled && ( + + ✓ Все видео загружены + + )} +
+ +
+ {SLOTS.map((slot, i) => ( + handleUpload(i, file)} + onRemove={() => handleRemove(i)} + /> + ))} +
+ +
+
+ + ПК — диагональный сплит из 3 видео +
+
+ + Телефон — только центральное видео +
+
+
+ ); } export default function HeroEditorPage() { @@ -15,6 +233,11 @@ export default function HeroEditorPage() { sectionKey="hero" title="Главный экран"> {(data, update) => ( <> + update({ ...data, videos })} + /> + 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 }); } diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index 6802444..0269556 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -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(null); const scrolledRef = useRef(false); + const overlayRef = useRef(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 (
- {/* Diagonal split background — 3 dancer videos */} -
- {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 ( -
+ {/* Mobile: single centered video */} +
+
+ + +
+
+ + {/* Desktop: diagonal split with all videos */} +
+ {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 ( +
+ +
+
+ ); + })} + {/* Gold diagonal lines between panels */} + + + + +
+ + )} + + {/* Loading overlay — covers videos but not content */} +
{/* Floating hearts */} diff --git a/src/data/content.ts b/src/data/content.ts index 96e2a41..9ba5543 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -12,6 +12,7 @@ export const siteContent: SiteContent = { "Открой для себя яркий, завораживающий и незабываемый мир танцев на пилоне!", ctaText: "Записаться", ctaHref: "#contact", + videos: ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"], }, about: { title: "О нас", diff --git a/src/types/content.ts b/src/types/content.ts index eeb7fdd..59bc49c 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -116,6 +116,7 @@ export interface SiteContent { subheadline: string; ctaText: string; ctaHref: string; + videos?: string[]; }; team: { title: string;