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:
2026-03-25 21:12:51 +03:00
parent e64119aaa0
commit 36ea952e9b
7 changed files with 413 additions and 286 deletions

View File

@@ -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<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 ---
function LocationSelect({