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
+114 -8
View File
@@ -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}
/>
</div>
);
@@ -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}
/>
<button
type="button"
@@ -510,7 +514,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
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}
/>
<button
type="button"
@@ -770,21 +774,21 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
value={item.place || ""}
onChange={(e) => update(i, "place", e.target.value)}
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
type="text"
value={item.category || ""}
onChange={(e) => update(i, "category", e.target.value)}
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
type="text"
value={item.competition || ""}
onChange={(e) => update(i, "competition", e.target.value)}
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
type="button"
@@ -828,3 +832,105 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
</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>
);
}