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:
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -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 { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
@@ -11,7 +11,11 @@ interface InputFieldProps {
|
|||||||
type?: "text" | "url" | "tel";
|
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({
|
export function InputField({
|
||||||
label,
|
label,
|
||||||
@@ -143,7 +147,7 @@ export function TextareaField({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -388,7 +392,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
|||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
placeholder={placeholder || "Добавить..."}
|
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}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -510,7 +514,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
placeholder={placeholder || "Добавить..."}
|
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}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -770,21 +774,21 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
|
|||||||
value={item.place || ""}
|
value={item.place || ""}
|
||||||
onChange={(e) => update(i, "place", e.target.value)}
|
onChange={(e) => update(i, "place", e.target.value)}
|
||||||
placeholder="1 место, финалист..."
|
placeholder="1 место, финалист..."
|
||||||
className="w-28 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
className={`w-28 shrink-0 ${smallInput}`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.category || ""}
|
value={item.category || ""}
|
||||||
onChange={(e) => update(i, "category", e.target.value)}
|
onChange={(e) => update(i, "category", e.target.value)}
|
||||||
placeholder="Категория"
|
placeholder="Категория"
|
||||||
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
className={`flex-1 ${smallInput}`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.competition || ""}
|
value={item.competition || ""}
|
||||||
onChange={(e) => update(i, "competition", e.target.value)}
|
onChange={(e) => update(i, "competition", e.target.value)}
|
||||||
placeholder="Чемпионат"
|
placeholder="Чемпионат"
|
||||||
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
className={`flex-1 ${smallInput}`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -828,3 +832,105 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Autocomplete Multi-Select ---
|
||||||
|
export 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(/\s*[,·]\s*/).filter(Boolean) : []), [value]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div
|
||||||
|
onClick={() => { 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 hover:border-gold/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected.map((item) => (
|
||||||
|
<span key={item} className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold">
|
||||||
|
{item}
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); removeItem(item); }} className="text-gold/60 hover:text-gold transition-colors">
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||||
|
{filtered.map((opt) => (
|
||||||
|
<button key={opt} type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => addItem(opt)}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors">
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
68
src/app/admin/_components/Toast.tsx
Normal file
68
src/app/admin/_components/Toast.tsx
Normal file
@@ -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<ToastContextValue>({
|
||||||
|
showError: () => {},
|
||||||
|
showSuccess: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ToastContext.Provider value={{ showError, showSuccess }}>
|
||||||
|
{children}
|
||||||
|
{toasts.length > 0 && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||||
|
t.type === "error"
|
||||||
|
? "bg-red-950/90 border-red-500/30 text-red-200"
|
||||||
|
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
|
||||||
|
<span className="flex-1">{t.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
|
||||||
|
className="shrink-0 text-neutral-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,68 +1,2 @@
|
|||||||
"use client";
|
// Re-export from shared location
|
||||||
|
export { ToastProvider, useToast } from "../_components/Toast";
|
||||||
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<ToastContextValue>({
|
|
||||||
showError: () => {},
|
|
||||||
showSuccess: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useToast() {
|
|
||||||
return useContext(ToastContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextId = 0;
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<ToastContext.Provider value={{ showError, showSuccess }}>
|
|
||||||
{children}
|
|
||||||
{toasts.length > 0 && (
|
|
||||||
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<div
|
|
||||||
key={t.id}
|
|
||||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
|
|
||||||
t.type === "error"
|
|
||||||
? "bg-red-950/90 border-red-500/30 text-red-200"
|
|
||||||
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
|
|
||||||
<span className="flex-1">{t.message}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
|
|
||||||
className="shrink-0 text-neutral-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField } from "../_components/FormField";
|
import { InputField } from "../_components/FormField";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
|
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 {
|
interface HeroData {
|
||||||
headline: string;
|
headline: string;
|
||||||
subheadline: string;
|
subheadline: string;
|
||||||
ctaText: string;
|
ctaText: string;
|
||||||
ctaHref: string;
|
|
||||||
videos?: string[];
|
videos?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +45,21 @@ function VideoSlot({
|
|||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -55,21 +77,31 @@ function VideoSlot({
|
|||||||
|
|
||||||
{/* Slot */}
|
{/* Slot */}
|
||||||
{src ? (
|
{src ? (
|
||||||
<div className={`group relative overflow-hidden rounded-lg border ${
|
<div
|
||||||
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
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
|
<video
|
||||||
|
ref={videoRef}
|
||||||
src={src}
|
src={src}
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay
|
preload="metadata"
|
||||||
className="aspect-[9/16] w-full object-cover bg-black"
|
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">
|
<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">
|
<p className="truncate text-xs text-neutral-400">
|
||||||
{src.split("/").pop()}
|
{src.split("/").pop()}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{isCenter && (
|
{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">
|
<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
|
MAIN
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={onRemove}
|
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"
|
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">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Upload size={24} />
|
<Upload size={24} />
|
||||||
<span className="text-xs font-medium">Загрузить</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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({
|
function VideoManager({
|
||||||
videos,
|
videos,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -147,7 +216,39 @@ function VideoManager({
|
|||||||
[onChange]
|
[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) {
|
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);
|
setUploadingIdx(idx);
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -214,7 +315,7 @@ function VideoManager({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1.5">
|
||||||
<Monitor size={13} />
|
<Monitor size={13} />
|
||||||
<span>ПК — диагональный сплит из 3 видео</span>
|
<span>ПК — диагональный сплит из 3 видео</span>
|
||||||
@@ -224,6 +325,15 @@ function VideoManager({
|
|||||||
<span>Телефон — только центральное видео</span>
|
<span>Телефон — только центральное видео</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,12 +363,6 @@ export default function HeroEditorPage() {
|
|||||||
value={data.ctaText}
|
value={data.ctaText}
|
||||||
onChange={(v) => update({ ...data, ctaText: v })}
|
onChange={(v) => update({ ...data, ctaText: v })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
|
||||||
label="Ссылка кнопки"
|
|
||||||
value={data.ctaHref}
|
|
||||||
onChange={(v) => update({ ...data, ctaHref: v })}
|
|
||||||
type="url"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
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 { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
@@ -39,139 +39,6 @@ interface MasterClassesData {
|
|||||||
items: MasterClassItem[];
|
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<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(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 (
|
|
||||||
<div ref={containerRef} className="relative">
|
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
|
||||||
{/* Selected chips + input */}
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
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) => (
|
|
||||||
<span
|
|
||||||
key={item}
|
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeItem(item);
|
|
||||||
}}
|
|
||||||
className="text-gold/60 hover:text-gold transition-colors"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown */}
|
|
||||||
{open && filtered.length > 0 && (
|
|
||||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
|
||||||
{filtered.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => addItem(opt)}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Location Select ---
|
// --- Location Select ---
|
||||||
function LocationSelect({
|
function LocationSelect({
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
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 { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
@@ -27,7 +28,13 @@ interface MemberForm {
|
|||||||
education: RichListItem[];
|
education: RichListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { ToastProvider } from "../../_components/Toast";
|
||||||
|
|
||||||
export default function TeamMemberEditorPage() {
|
export default function TeamMemberEditorPage() {
|
||||||
|
return <ToastProvider><TeamMemberEditor /></ToastProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamMemberEditor() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isNew = id === "new";
|
const isNew = id === "new";
|
||||||
@@ -45,8 +52,9 @@ export default function TeamMemberEditorPage() {
|
|||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(!isNew);
|
const [loading, setLoading] = useState(!isNew);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const [styles, setStyles] = useState<string[]>([]);
|
||||||
|
|
||||||
// Instagram validation
|
// Instagram validation
|
||||||
const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||||
@@ -107,6 +115,16 @@ export default function TeamMemberEditorPage() {
|
|||||||
}, 500);
|
}, 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(() => {
|
useEffect(() => {
|
||||||
if (isNew) return;
|
if (isNew) return;
|
||||||
adminFetch(`/api/admin/team/${id}`)
|
adminFetch(`/api/admin/team/${id}`)
|
||||||
@@ -130,38 +148,45 @@ export default function TeamMemberEditorPage() {
|
|||||||
}, [id, isNew]);
|
}, [id, isNew]);
|
||||||
|
|
||||||
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
|
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const initialLoadRef = useRef(true);
|
||||||
|
|
||||||
async function handleSave() {
|
// Auto-save with 800ms debounce (existing members only)
|
||||||
if (hasErrors) return;
|
useEffect(() => {
|
||||||
setSaving(true);
|
if (isNew || loading || initialLoadRef.current) {
|
||||||
setSaved(false);
|
initialLoadRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasErrors || !data.name || !data.role) return;
|
||||||
|
|
||||||
// Build instagram as full URL for storage if username is provided
|
clearTimeout(saveTimerRef.current);
|
||||||
const payload = {
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
...data,
|
setSaving(true);
|
||||||
instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "",
|
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 {
|
|
||||||
const res = await adminFetch(`/api/admin/team/${id}`, {
|
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) showSuccess("Сохранено");
|
||||||
setSaved(true);
|
else showError("Ошибка сохранения");
|
||||||
setTimeout(() => setSaved(false), 2000);
|
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);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +217,16 @@ export default function TeamMemberEditorPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-neutral-400">
|
<div className="animate-pulse space-y-4">
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<div className="h-8 w-48 bg-neutral-800 rounded-lg" />
|
||||||
Загрузка...
|
<div className="flex gap-6 items-start">
|
||||||
|
<div className="w-[130px] shrink-0 aspect-[3/4] bg-neutral-800 rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -213,42 +245,41 @@ export default function TeamMemberEditorPage() {
|
|||||||
{isNew ? "Новый участник" : data.name}
|
{isNew ? "Новый участник" : data.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isNew ? (
|
||||||
onClick={handleSave}
|
<button
|
||||||
disabled={saving || !data.name || !data.role || hasErrors || igStatus === "checking"}
|
onClick={handleSaveNew}
|
||||||
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
disabled={saving || !data.name || !data.role || hasErrors || igStatus === "checking"}
|
||||||
>
|
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||||
{saving ? (
|
>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
) : saved ? (
|
{saving ? "Сохранение..." : "Создать"}
|
||||||
<Check size={16} />
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
<Save size={16} />
|
|
||||||
)}
|
|
||||||
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
|
<div className="mt-6 flex gap-6 items-start">
|
||||||
{/* Photo */}
|
{/* Photo */}
|
||||||
<div>
|
<div className="shrink-0">
|
||||||
<p className="text-sm text-neutral-400 mb-2">Фото</p>
|
<p className="text-sm text-neutral-400 mb-2">Фото</p>
|
||||||
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-white/10">
|
<label className="relative block w-[130px] aspect-[3/4] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||||
<Image
|
<Image
|
||||||
src={data.image}
|
src={data.image}
|
||||||
alt={data.name || "Фото"}
|
alt={data.name || "Фото"}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="240px"
|
sizes="130px"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||||
<label className="mt-3 flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
{uploading ? (
|
||||||
{uploading ? (
|
<Loader2 size={20} className="animate-spin text-white" />
|
||||||
<Loader2 size={16} className="animate-spin" />
|
) : (
|
||||||
) : (
|
<>
|
||||||
<Upload size={16} />
|
<Upload size={20} className="text-white" />
|
||||||
)}
|
<span className="text-[11px] text-white/80">Изменить фото</span>
|
||||||
{uploading ? "Загрузка..." : "Загрузить фото"}
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
@@ -260,15 +291,32 @@ export default function TeamMemberEditorPage() {
|
|||||||
|
|
||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<InputField
|
<div className="grid grid-cols-2 gap-3">
|
||||||
label="Имя"
|
<InputField
|
||||||
value={data.name}
|
label="Имя"
|
||||||
onChange={(v) => setData({ ...data, name: v })}
|
value={data.name.split(" ")[0] || ""}
|
||||||
/>
|
onChange={(v) => {
|
||||||
<InputField
|
const last = data.name.split(" ").slice(1).join(" ");
|
||||||
|
setData({ ...data, name: last ? `${v} ${last}` : v });
|
||||||
|
}}
|
||||||
|
placeholder="Анна"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Фамилия"
|
||||||
|
value={data.name.split(" ").slice(1).join(" ") || ""}
|
||||||
|
onChange={(v) => {
|
||||||
|
const first = data.name.split(" ")[0] || "";
|
||||||
|
setData({ ...data, name: v ? `${first} ${v}` : first });
|
||||||
|
}}
|
||||||
|
placeholder="Тарыба"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AutocompleteMulti
|
||||||
label="Роль / Специализация"
|
label="Роль / Специализация"
|
||||||
value={data.role}
|
value={data.role}
|
||||||
onChange={(v) => setData({ ...data, role: v })}
|
onChange={(v) => setData({ ...data, role: v })}
|
||||||
|
options={styles}
|
||||||
|
placeholder="Добавить стиль..."
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user