Files
blackheart-website/src/app/admin/hero/page.tsx
T
diana.dolgolyova e56a6a1608 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
2026-03-29 22:17:11 +03:00

376 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useCallback, useEffect } 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";
const MAX_VIDEO_SIZE_MB = 10;
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
interface HeroData {
headline: string;
subheadline: string;
ctaText: 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);
const videoRef = useRef<HTMLVideoElement>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
// Fetch file size via HEAD request
useEffect(() => {
if (!src) { setFileSize(null); return; }
fetch(src, { method: "HEAD" })
.then((r) => {
const len = r.headers.get("content-length");
if (len) setFileSize(parseInt(len, 10));
})
.catch(() => {});
}, [src]);
const isLarge = fileSize !== null && fileSize > MAX_VIDEO_SIZE_BYTES;
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"
}`}
onMouseEnter={() => videoRef.current?.play()}
onMouseLeave={() => { videoRef.current?.pause(); }}
>
<video
ref={videoRef}
src={src}
muted
loop
playsInline
preload="metadata"
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>
{fileSize !== null && (
<p className={`text-[10px] mt-0.5 ${isLarge ? "text-amber-400" : "text-neutral-500"}`}>
{formatFileSize(fileSize)}{isLarge ? ` — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ` : ""}
</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>
)}
{/* Play hint */}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<span className="text-white/80 text-xs"> наведите для просмотра</span>
</div>
<button
onClick={onRemove}
aria-label={`Удалить видео: ${label}`}
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, до {MAX_VIDEO_SIZE_MB} МБ</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 VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; totalMb: number; rating: { label: string; color: string } }) {
const [open, setOpen] = useState(false);
return (
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-lg bg-neutral-800/50 px-3 py-2 transition-colors hover:bg-neutral-800/80"
>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span>
<span className={`text-[11px] ${rating.color}`}>{rating.label} {open ? "▲" : "▼"}</span>
</div>
{open && (
<table className="w-full text-[10px] text-neutral-500 mt-2">
<tbody>
<tr className={totalMb <= 15 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5 w-20">до 15 МБ</td><td>Быстро видео загружается мгновенно</td>
</tr>
<tr className={totalMb > 15 && totalMb <= 24 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5">1524 МБ</td><td>Нормально небольшая задержка на 4G</td>
</tr>
<tr className={totalMb > 24 && totalMb <= 40 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5">2440 МБ</td><td>Медленно заметная задержка на телефоне</td>
</tr>
<tr className={totalMb > 40 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5">40+ МБ</td><td>Очень медленно пользователь может уйти</td>
</tr>
</tbody>
</table>
)}
</button>
);
}
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);
// Save all 3 slots (empty string for unfilled) to preserve positions
onChange(updated.map((s) => s || ""));
},
[onChange]
);
const [sizeWarning, setSizeWarning] = useState<string | null>(null);
const [fileSizes, setFileSizes] = useState<(number | null)[]>([null, null, null]);
// Fetch file sizes for all slots
useEffect(() => {
slots.forEach((src, i) => {
if (!src) { setFileSizes((p) => { const n = [...p]; n[i] = null; return n; }); return; }
fetch(src, { method: "HEAD" })
.then((r) => {
const len = r.headers.get("content-length");
if (len) setFileSizes((p) => { const n = [...p]; n[i] = parseInt(len, 10); return n; });
})
.catch(() => {});
});
}, [slots]);
const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
const totalMb = totalSize / (1024 * 1024);
function getLoadRating(mb: number): { label: string; color: string } {
if (mb <= 15) return { label: "Быстрая загрузка", color: "text-emerald-400" };
if (mb <= 24) return { label: "Нормальная загрузка", color: "text-blue-400" };
if (mb <= 40) return { label: "Медленная загрузка", color: "text-amber-400" };
return { label: "Очень медленная загрузка", color: "text-red-400" };
}
async function handleUpload(idx: number, file: File) {
if (file.size > MAX_VIDEO_SIZE_BYTES) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
alert(`Видео ${sizeMb} МБ — максимум ${MAX_VIDEO_SIZE_MB} МБ. Сожмите видео и попробуйте снова.`);
return;
}
setSizeWarning(null);
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 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);
}
}
function handleRemove(idx: number) {
const updated = [...slots];
updated[idx] = null;
syncToParent(updated);
}
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 max-w-3xl">
{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 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>
{sizeWarning && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-xs text-amber-400">
{sizeWarning}
</div>
)}
{/* Total size — collapsible */}
{totalSize > 0 && <VideoSizeInfo totalSize={totalSize} totalMb={totalMb} rating={getLoadRating(totalMb)} />}
</div>
);
}
export default function HeroEditorPage() {
return (
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
{(data, update) => (
<>
<VideoManager
videos={data.videos || []}
onChange={(videos) => update({ ...data, videos })}
/>
<InputField
label="Заголовок"
value={data.headline || ""}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline || ""}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText || ""}
onChange={(v) => update({ ...data, ctaText: v })}
/>
</>
)}
</SectionEditor>
);
}