77ad2a6b68
Public site: skip-to-content link, mobile menu focus trap + Escape key, aria-current on nav, keyboard navigation for carousels/tabs/articles, ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog), form labels + aria-describedby, 44px touch targets, semantic HTML (<time>, <del>), prefers-reduced-motion on Hero scroll hijack, mobile schedule filters, URL hash sync on scroll for correct refresh. Admin panel: password toggle aria-label, toast aria-live regions, SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid on validation errors, sidebar hamburger aria-label/expanded, nav aria-label, ArrayEditor aria-expanded on collapsible items.
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
"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 = 8;
|
||
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">15–24 МБ</td><td>Нормально — небольшая задержка на 4G</td>
|
||
</tr>
|
||
<tr className={totalMb > 24 && totalMb <= 40 ? `${rating.color} font-medium` : ""}>
|
||
<td className="py-0.5">24–40 МБ</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);
|
||
// Only propagate when all 3 are filled
|
||
if (updated.every((s) => s !== null)) {
|
||
onChange(updated as string[]);
|
||
}
|
||
},
|
||
[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);
|
||
setSizeWarning(`Видео ${sizeMb} МБ — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ для быстрой загрузки`);
|
||
} else {
|
||
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 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 (
|
||
<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>
|
||
);
|
||
}
|