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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user