feat: admin UX — shared input classes, autocomplete role, auto-save team, video improvements
- Extract base input classes (baseInput, textAreaInput, smallInput, dashedInput) with gold hover - Move AutocompleteMulti to shared FormField, support · separator - Team editor: auto-save with toast, split name into first/last, autocomplete role from class styles - Team photo: click-to-upload overlay, smaller 130px thumbnail - Hero videos: play on hover, file size display, 8MB warning, total size performance table - Remove ctaHref field from Hero admin (unused on frontend) - Move Toast to shared _components for reuse across admin pages
This commit is contained in:
+118
-14
@@ -1,16 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
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;
|
||||
ctaHref: string;
|
||||
videos?: string[];
|
||||
}
|
||||
|
||||
@@ -38,6 +45,21 @@ function VideoSlot({
|
||||
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">
|
||||
@@ -55,21 +77,31 @@ function VideoSlot({
|
||||
|
||||
{/* Slot */}
|
||||
{src ? (
|
||||
<div className={`group relative overflow-hidden rounded-lg border ${
|
||||
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||
}`}>
|
||||
<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
|
||||
autoPlay
|
||||
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">
|
||||
@@ -77,6 +109,10 @@ function VideoSlot({
|
||||
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}
|
||||
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"
|
||||
@@ -101,7 +137,7 @@ function VideoSlot({
|
||||
<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, до 50МБ</span>
|
||||
<span className="text-[10px] opacity-60">MP4, до {MAX_VIDEO_SIZE_MB} МБ</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -122,6 +158,39 @@ function VideoSlot({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -147,7 +216,39 @@ function VideoManager({
|
||||
[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, 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();
|
||||
@@ -214,7 +315,7 @@ function VideoManager({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 rounded-lg bg-neutral-800/50 p-3 text-xs text-neutral-500">
|
||||
<div className="flex gap-4 text-xs text-neutral-500">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Monitor size={13} />
|
||||
<span>ПК — диагональный сплит из 3 видео</span>
|
||||
@@ -224,6 +325,15 @@ function VideoManager({
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -253,12 +363,6 @@ export default function HeroEditorPage() {
|
||||
value={data.ctaText}
|
||||
onChange={(v) => update({ ...data, ctaText: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Ссылка кнопки"
|
||||
value={data.ctaHref}
|
||||
onChange={(v) => update({ ...data, ctaHref: v })}
|
||||
type="url"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SectionEditor>
|
||||
|
||||
Reference in New Issue
Block a user