diff --git a/public/images/news/saveclip-app-658751600-18313798510265536-337520394-1774441073467.jpg b/public/images/news/saveclip-app-658751600-18313798510265536-337520394-1774441073467.jpg new file mode 100644 index 0000000..975b0e2 Binary files /dev/null and b/public/images/news/saveclip-app-658751600-18313798510265536-337520394-1774441073467.jpg differ diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx index ea61d5f..123f552 100644 --- a/src/app/admin/_components/FormField.tsx +++ b/src/app/admin/_components/FormField.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useMemo } from "react"; import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import type { RichListItem, VictoryItem } from "@/types/content"; @@ -11,7 +11,11 @@ interface InputFieldProps { type?: "text" | "url" | "tel"; } -const inputCls = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"; +const baseInput = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors"; +const textAreaInput = `${baseInput} resize-none overflow-hidden`; +const smallInput = "rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 focus:border-gold transition-colors"; +const dashedInput = "flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors"; +const inputCls = baseInput; export function InputField({ label, @@ -143,7 +147,7 @@ export function TextareaField({ onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows} - className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none overflow-hidden" + className={textAreaInput} /> ); @@ -388,7 +392,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} placeholder={placeholder || "Добавить..."} - className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors" + className={dashedInput} /> + + ))} + { setQuery(e.target.value); setOpen(true); }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + placeholder={selected.length === 0 ? placeholder : ""} + className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none" + /> + + {open && filtered.length > 0 && ( +
+ {filtered.map((opt) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/admin/_components/Toast.tsx b/src/app/admin/_components/Toast.tsx new file mode 100644 index 0000000..27521a2 --- /dev/null +++ b/src/app/admin/_components/Toast.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState, useEffect, useCallback, createContext, useContext } from "react"; +import { X, AlertCircle, CheckCircle2 } from "lucide-react"; + +interface ToastItem { + id: number; + message: string; + type: "error" | "success"; +} + +interface ToastContextValue { + showError: (message: string) => void; + showSuccess: (message: string) => void; +} + +const ToastContext = createContext({ + showError: () => {}, + showSuccess: () => {}, +}); + +export function useToast() { + return useContext(ToastContext); +} + +let nextId = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message: string, type: "error" | "success") => { + const id = ++nextId; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }, []); + + const showError = useCallback((message: string) => addToast(message, "error"), [addToast]); + const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]); + + return ( + + {children} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
+ {t.type === "error" ? : } + {t.message} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/bookings/Toast.tsx b/src/app/admin/bookings/Toast.tsx index 27521a2..e304211 100644 --- a/src/app/admin/bookings/Toast.tsx +++ b/src/app/admin/bookings/Toast.tsx @@ -1,68 +1,2 @@ -"use client"; - -import { useState, useEffect, useCallback, createContext, useContext } from "react"; -import { X, AlertCircle, CheckCircle2 } from "lucide-react"; - -interface ToastItem { - id: number; - message: string; - type: "error" | "success"; -} - -interface ToastContextValue { - showError: (message: string) => void; - showSuccess: (message: string) => void; -} - -const ToastContext = createContext({ - showError: () => {}, - showSuccess: () => {}, -}); - -export function useToast() { - return useContext(ToastContext); -} - -let nextId = 0; - -export function ToastProvider({ children }: { children: React.ReactNode }) { - const [toasts, setToasts] = useState([]); - - const addToast = useCallback((message: string, type: "error" | "success") => { - const id = ++nextId; - setToasts((prev) => [...prev, { id, message, type }]); - setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); - }, []); - - const showError = useCallback((message: string) => addToast(message, "error"), [addToast]); - const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]); - - return ( - - {children} - {toasts.length > 0 && ( -
- {toasts.map((t) => ( -
- {t.type === "error" ? : } - {t.message} - -
- ))} -
- )} -
- ); -} +// Re-export from shared location +export { ToastProvider, useToast } from "../_components/Toast"; diff --git a/src/app/admin/hero/page.tsx b/src/app/admin/hero/page.tsx index 956f04a..256ad11 100644 --- a/src/app/admin/hero/page.tsx +++ b/src/app/admin/hero/page.tsx @@ -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(null); + const videoRef = useRef(null); + const [fileSize, setFileSize] = useState(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 (
@@ -55,21 +77,31 @@ function VideoSlot({ {/* Slot */} {src ? ( -
+
videoRef.current?.play()} + onMouseLeave={() => { videoRef.current?.pause(); }} + >
)} @@ -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 ( + + ); +} + function VideoManager({ videos, onChange, @@ -147,7 +216,39 @@ function VideoManager({ [onChange] ); + const [sizeWarning, setSizeWarning] = useState(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({ ))}
-
+
ПК — диагональный сплит из 3 видео @@ -224,6 +325,15 @@ function VideoManager({ Телефон — только центральное видео
+ + {sizeWarning && ( +
+ ⚠ {sizeWarning} +
+ )} + + {/* Total size — collapsible */} + {totalSize > 0 && }
); } @@ -253,12 +363,6 @@ export default function HeroEditorPage() { value={data.ctaText} onChange={(v) => update({ ...data, ctaText: v })} /> - update({ ...data, ctaHref: v })} - type="url" - /> )} diff --git a/src/app/admin/master-classes/page.tsx b/src/app/admin/master-classes/page.tsx index 7e8535f..e56a80b 100644 --- a/src/app/admin/master-classes/page.tsx +++ b/src/app/admin/master-classes/page.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { SectionEditor } from "../_components/SectionEditor"; -import { InputField, TextareaField, ParticipantLimits } from "../_components/FormField"; +import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField"; import { ArrayEditor } from "../_components/ArrayEditor"; import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; @@ -39,139 +39,6 @@ interface MasterClassesData { items: MasterClassItem[]; } -// --- Autocomplete Multi-Select --- -function AutocompleteMulti({ - label, - value, - onChange, - options, - placeholder, -}: { - label: string; - value: string; - onChange: (v: string) => void; - options: string[]; - placeholder?: string; -}) { - const selected = useMemo(() => (value ? value.split(", ").filter(Boolean) : []), [value]); - const [query, setQuery] = useState(""); - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - const inputRef = useRef(null); - - const filtered = useMemo(() => { - if (!query) return options.filter((o) => !selected.includes(o)); - const q = query.toLowerCase(); - return options.filter( - (o) => !selected.includes(o) && o.toLowerCase().includes(q) - ); - }, [query, options, selected]); - - useEffect(() => { - if (!open) return; - function handle(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - setQuery(""); - } - } - document.addEventListener("mousedown", handle); - return () => document.removeEventListener("mousedown", handle); - }, [open]); - - function addItem(item: string) { - onChange([...selected, item].join(", ")); - setQuery(""); - inputRef.current?.focus(); - } - - function removeItem(item: string) { - onChange(selected.filter((s) => s !== item).join(", ")); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter") { - e.preventDefault(); - if (filtered.length > 0) { - addItem(filtered[0]); - } else if (query.trim()) { - addItem(query.trim()); - } - } - if (e.key === "Backspace" && !query && selected.length > 0) { - removeItem(selected[selected.length - 1]); - } - if (e.key === "Escape") { - setOpen(false); - setQuery(""); - } - } - - return ( -
- - {/* Selected chips + input */} -
{ - setOpen(true); - inputRef.current?.focus(); - }} - className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${ - open ? "border-gold" : "border-white/10" - }`} - > - {selected.map((item) => ( - - {item} - - - ))} - { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onKeyDown={handleKeyDown} - placeholder={selected.length === 0 ? placeholder : ""} - className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none" - /> -
- - {/* Dropdown */} - {open && filtered.length > 0 && ( -
- {filtered.map((opt) => ( - - ))} -
- )} -
- ); -} // --- Location Select --- function LocationSelect({ diff --git a/src/app/admin/team/[id]/page.tsx b/src/app/admin/team/[id]/page.tsx index 9307b64..c872d41 100644 --- a/src/app/admin/team/[id]/page.tsx +++ b/src/app/admin/team/[id]/page.tsx @@ -4,7 +4,8 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useParams } from "next/navigation"; import Image from "next/image"; import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react"; -import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField"; +import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField, AutocompleteMulti } from "../../_components/FormField"; +import { useToast } from "../../_components/Toast"; import { adminFetch } from "@/lib/csrf"; import type { RichListItem, VictoryItem } from "@/types/content"; @@ -27,7 +28,13 @@ interface MemberForm { education: RichListItem[]; } +import { ToastProvider } from "../../_components/Toast"; + export default function TeamMemberEditorPage() { + return ; +} + +function TeamMemberEditor() { const router = useRouter(); const { id } = useParams<{ id: string }>(); const isNew = id === "new"; @@ -45,8 +52,9 @@ export default function TeamMemberEditorPage() { }); const [loading, setLoading] = useState(!isNew); const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); const [uploading, setUploading] = useState(false); + const { showSuccess, showError } = useToast(); + const [styles, setStyles] = useState([]); // Instagram validation const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle"); @@ -107,6 +115,16 @@ export default function TeamMemberEditorPage() { }, 500); }, []); + // Fetch class styles for role autocomplete + useEffect(() => { + adminFetch("/api/admin/sections/classes") + .then((r) => r.json()) + .then((data: { items?: { name: string }[] }) => { + setStyles(data.items?.map((i) => i.name) ?? []); + }) + .catch(() => {}); + }, []); + useEffect(() => { if (isNew) return; adminFetch(`/api/admin/team/${id}`) @@ -130,38 +148,45 @@ export default function TeamMemberEditorPage() { }, [id, isNew]); const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0; + const saveTimerRef = useRef>(undefined); + const initialLoadRef = useRef(true); - async function handleSave() { - if (hasErrors) return; - setSaving(true); - setSaved(false); + // Auto-save with 800ms debounce (existing members only) + useEffect(() => { + if (isNew || loading || initialLoadRef.current) { + initialLoadRef.current = false; + return; + } + if (hasErrors || !data.name || !data.role) return; - // Build instagram as full URL for storage if username is provided - const payload = { - ...data, - instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "", - }; - - if (isNew) { - const res = await adminFetch("/api/admin/team", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (res.ok) { - router.push("/admin/team"); - } - } else { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(async () => { + setSaving(true); + const payload = { ...data, instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "" }; const res = await adminFetch(`/api/admin/team/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - if (res.ok) { - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } - } + if (res.ok) showSuccess("Сохранено"); + else showError("Ошибка сохранения"); + setSaving(false); + }, 800); + + return () => clearTimeout(saveTimerRef.current); + }, [data, isNew, loading, hasErrors, id]); + + // Manual save for new members + async function handleSaveNew() { + if (hasErrors || !data.name || !data.role) return; + setSaving(true); + const payload = { ...data, instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "" }; + const res = await adminFetch("/api/admin/team", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (res.ok) router.push("/admin/team"); setSaving(false); } @@ -192,9 +217,16 @@ export default function TeamMemberEditorPage() { if (loading) { return ( -
- - Загрузка... +
+
+
+
+
+
+
+
+
+
); } @@ -213,42 +245,41 @@ export default function TeamMemberEditorPage() { {isNew ? "Новый участник" : data.name}
- + {isNew ? ( + + ) : null}
-
+
{/* Photo */} -
+

Фото

-
+
-
+ setData({ ...data, role: v })} + options={styles} + placeholder="Добавить стиль..." />