Files
blackheart-website/src/app/admin/hero/page.tsx
T
diana.dolgolyova 77ad2a6b68 fix: comprehensive UI/UX accessibility and usability improvements
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.
2026-03-29 20:42:14 +03:00

372 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 = 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">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);
// 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>
);
}