fix: remove fallback content, fix video upload and positioning

- Remove hardcoded fallback data — DB is sole content source
- Sections render conditionally when data exists
- Hero video slots save after each upload (not only when all 3 filled)
- Video positions preserved (left/center/right) with empty string slots
- Client-side 10MB hard limit on video uploads with clear error
- Server-side upload error handling for body size limits
- Guard Team section against empty members array
- Clean up old uploaded images and videos
This commit is contained in:
2026-03-29 22:17:11 +03:00
parent 77ad2a6b68
commit e56a6a1608
66 changed files with 75 additions and 66 deletions
+19 -15
View File
@@ -6,7 +6,7 @@ import { InputField } from "../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
const MAX_VIDEO_SIZE_MB = 8;
const MAX_VIDEO_SIZE_MB = 10;
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
function formatFileSize(bytes: number): string {
@@ -209,10 +209,8 @@ function VideoManager({
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[]);
}
// Save all 3 slots (empty string for unfilled) to preserve positions
onChange(updated.map((s) => s || ""));
},
[onChange]
);
@@ -246,10 +244,10 @@ function VideoManager({
async function handleUpload(idx: number, file: File) {
if (file.size > MAX_VIDEO_SIZE_BYTES) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
setSizeWarning(`Видео ${sizeMb} МБ — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ для быстрой загрузки`);
} else {
setSizeWarning(null);
alert(`Видео ${sizeMb} МБ — максимум ${MAX_VIDEO_SIZE_MB} МБ. Сожмите видео и попробуйте снова.`);
return;
}
setSizeWarning(null);
setUploadingIdx(idx);
try {
const form = new FormData();
@@ -260,14 +258,21 @@ function VideoManager({
body: form,
});
if (!res.ok) {
const err = await res.json();
alert(err.error || "Ошибка загрузки");
const text = await res.text();
let msg = "Ошибка загрузки";
try {
const err = JSON.parse(text);
msg = err.error || msg;
} catch { /* empty response */ }
alert(`${msg} (${res.status})`);
return;
}
const { path } = await res.json();
const updated = [...slots];
updated[idx] = path;
syncToParent(updated);
} catch (e) {
alert(`Ошибка сети: ${e instanceof Error ? e.message : "попробуйте снова"}`);
} finally {
setUploadingIdx(null);
}
@@ -276,8 +281,7 @@ function VideoManager({
function handleRemove(idx: number) {
const updated = [...slots];
updated[idx] = null;
setSlots(updated);
// Don't propagate incomplete state — keep old saved videos in DB
syncToParent(updated);
}
const allFilled = slots.every((s) => s !== null);
@@ -351,17 +355,17 @@ export default function HeroEditorPage() {
<InputField
label="Заголовок"
value={data.headline}
value={data.headline || ""}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline}
value={data.subheadline || ""}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText}
value={data.ctaText || ""}
onChange={(v) => update({ ...data, ctaText: v })}
/>
</>
+3 -10
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
import { siteContent } from "@/data/content";
import { revalidatePath } from "next/cache";
import { invalidateContentCache } from "@/lib/content";
@@ -12,16 +11,10 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
}
let data = getSection(key);
const data = getSection(key);
if (!data) {
// Auto-seed from fallback content if section doesn't exist yet
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
if (fallback) {
setSection(key, fallback);
data = fallback;
} else {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
// Return empty object so admin editor can initialize fields
return NextResponse.json({});
}
return NextResponse.json(data, {
+16 -7
View File
@@ -10,10 +10,19 @@ 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
const VIDEO_MAX_SIZE = 10 * 1024 * 1024; // 10MB
export async function POST(request: NextRequest) {
const formData = await request.formData();
let formData: FormData;
try {
formData = await request.formData();
} catch (e) {
const msg = e instanceof Error ? e.message : "Upload failed";
return NextResponse.json(
{ error: msg.includes("size") || msg.includes("multipart") ? "Файл слишком большой (макс. 10 МБ)" : `Ошибка загрузки: ${msg}` },
{ status: 413 }
);
}
const file = formData.get("file") as File | null;
const rawFolder = (formData.get("folder") as string) || "team";
const folder = ALL_FOLDERS.includes(rawFolder) ? rawFolder : "team";
@@ -26,16 +35,16 @@ export async function POST(request: NextRequest) {
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";
? "Допустимы только MP4 и WebM"
: "Допустимы только JPEG, PNG, WebP и AVIF";
return NextResponse.json({ error: msg }, { status: 400 });
}
const maxSize = isVideoFolder ? VIDEO_MAX_SIZE : IMAGE_MAX_SIZE;
if (file.size > maxSize) {
const label = isVideoFolder ? "50MB" : "5MB";
const label = isVideoFolder ? "10 МБ" : "5 МБ";
return NextResponse.json(
{ error: `File too large (max ${label})` },
{ error: `Файл слишком большой (макс. ${label})` },
{ status: 400 }
);
}
@@ -45,7 +54,7 @@ export async function POST(request: NextRequest) {
const allowedExts = isVideoFolder ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
if (!allowedExts.includes(ext)) {
return NextResponse.json(
{ error: "Invalid file extension" },
{ error: "Недопустимое расширение файла" },
{ status: 400 }
);
}
+7 -5
View File
@@ -22,13 +22,15 @@ const oswald = localFont({
});
export function generateMetadata(): Metadata {
const { meta } = getContent();
const content = getContent();
const title = content?.meta?.title || "BLACK HEART DANCE HOUSE";
const description = content?.meta?.description || "";
return {
title: meta.title,
description: meta.description,
title,
description,
openGraph: {
title: meta.title,
description: meta.description,
title,
description,
locale: "ru_RU",
type: "website",
},
+20 -18
View File
@@ -33,24 +33,26 @@ export default function HomePage() {
<ClientShell>
<Header />
<main id="main-content">
<Hero data={content.hero} />
<About
data={content.about}
stats={{
trainers: content.team.members.length,
classes: content.classes.items.length,
locations: content.schedule.locations.length,
}}
/>
<Classes data={content.classes} />
<Team data={content.team} schedule={content.schedule.locations} />
{openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />}
<Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes.items} teamMembers={content.team.members} />
<Pricing data={content.pricing} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
<News data={content.news} />
<FAQ data={content.faq} />
<Contact data={content.contact} />
{content?.hero && <Hero data={content.hero} />}
{content?.about && (
<About
data={content.about}
stats={{
trainers: content.team?.members?.length ?? 0,
classes: content.classes?.items?.length ?? 0,
locations: content.schedule?.locations?.length ?? 0,
}}
/>
)}
{content?.classes && <Classes data={content.classes} />}
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} />}
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} />}
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
{content?.pricing && <Pricing data={content.pricing} />}
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />}
{content?.news && <News data={content.news} />}
{content?.faq && <FAQ data={content.faq} />}
{content?.contact && <Contact data={content.contact} />}
<BackToTop />
<FloatingContact />
</main>
+6 -4
View File
@@ -20,9 +20,10 @@ export function Hero({ data: hero }: HeroProps) {
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)
const hasVideos = hero.videos?.some(Boolean);
const videos = hasVideos ? hero.videos! : DEFAULT_VIDEOS;
const centerVideo = videos[1] || videos[0];
const totalVideos = videos.slice(0, 3).filter(Boolean).length + 1; // desktop (filled) + mobile (1)
const prefersReducedMotion = useRef(false);
@@ -106,6 +107,7 @@ export function Hero({ data: hero }: HeroProps) {
{/* Desktop: diagonal split with all videos */}
<div className="absolute inset-0 hidden md:block">
{videos.slice(0, 3).map((src, i) => {
if (!src) return null;
const positions = [
{ left: "0%", width: "38%" },
{ left: "31%", width: "38%" },
@@ -118,7 +120,7 @@ export function Hero({ data: hero }: HeroProps) {
];
return (
<div
key={src}
key={`video-${i}`}
className="absolute top-0 bottom-0 overflow-hidden"
style={{
left: positions[i].left,
+1
View File
@@ -14,6 +14,7 @@ interface TeamProps {
}
export function Team({ data: team, schedule }: TeamProps) {
if (!team?.members?.length) return null;
const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false);
+3 -4
View File
@@ -1,11 +1,10 @@
import { getSiteContent } from "@/lib/db";
import { siteContent as fallback } from "@/data/content";
import type { SiteContent } from "@/types/content";
let cached: { data: SiteContent; expiresAt: number } | null = null;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function getContent(): SiteContent {
export function getContent(): SiteContent | null {
const now = Date.now();
if (cached && now < cached.expiresAt) {
return cached.data;
@@ -17,9 +16,9 @@ export function getContent(): SiteContent {
cached = { data: content, expiresAt: now + CACHE_TTL };
return content;
}
return fallback;
return null;
} catch {
return fallback;
return null;
}
}