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
+27 -16
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 });
}