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>