import { useRef, useEffect, useState, useMemo, useCallback } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle, Bold, Italic, List, Heading2, Pencil } from "lucide-react";
import { formatMarkup } from "@/lib/markup";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem } from "@/types/content";
interface InputFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: "text" | "url" | "tel";
}
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,
value,
onChange,
placeholder,
type = "text",
}: InputFieldProps) {
return (
{label}
onChange(e.target.value)}
placeholder={placeholder}
className={inputCls}
/>
);
}
export function ParticipantLimits({
min,
max,
onMinChange,
onMaxChange,
}: {
min: number;
max: number;
onMinChange: (v: number) => void;
onMaxChange: (v: number) => void;
}) {
const [minStr, setMinStr] = useState(String(min));
const [maxStr, setMaxStr] = useState(String(max));
const minLocal = parseInt(minStr) || 0;
const maxLocal = parseInt(maxStr) || 0;
const minEmpty = minStr === "";
const maxEmpty = maxStr === "";
const maxError = (maxLocal > 0 && minLocal > 0 && maxLocal < minLocal) || minEmpty || maxEmpty;
function handleMin(raw: string) {
setMinStr(raw);
if (raw === "") return;
const v = parseInt(raw) || 0;
const curMax = parseInt(maxStr) || 0;
if (curMax > 0 && v > curMax) return;
onMinChange(v);
}
function handleMax(raw: string) {
setMaxStr(raw);
if (raw === "") return;
const v = parseInt(raw) || 0;
const curMin = parseInt(minStr) || 0;
if (v > 0 && v < curMin) return;
onMaxChange(v);
}
const errorMsg = minEmpty || maxEmpty
? "Поле не может быть пустым"
: maxLocal > 0 && minLocal > maxLocal
? "Макс. не может быть меньше мин."
: null;
return (
);
}
interface TextareaFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function TextareaField({
label,
value,
onChange,
placeholder,
rows = 3,
}: TextareaFieldProps) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return (
{label}
);
}
interface RichTextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function RichTextarea({
label,
value,
onChange,
placeholder,
rows = 4,
}: RichTextareaProps) {
const ref = useRef(null);
const [editing, setEditing] = useState(false);
const hasContent = Boolean(value?.trim());
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const wrapSelection = useCallback((before: string, after: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const text = value ?? "";
const selected = text.slice(start, end);
// If already wrapped, unwrap
const beforeCheck = text.slice(Math.max(0, start - before.length), start);
const afterCheck = text.slice(end, end + after.length);
if (beforeCheck === before && afterCheck === after) {
const newText = text.slice(0, start - before.length) + selected + text.slice(end + after.length);
onChange(newText);
requestAnimationFrame(() => {
el.selectionStart = start - before.length;
el.selectionEnd = end - before.length;
el.focus();
});
return;
}
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
onChange(newText);
requestAnimationFrame(() => {
if (selected) {
el.selectionStart = start + before.length;
el.selectionEnd = end + before.length;
} else {
el.selectionStart = start + before.length;
el.selectionEnd = start + before.length;
}
el.focus();
});
}, [value, onChange]);
const insertAtCursor = useCallback((text: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const current = value ?? "";
// Add newline before if not at start of line
const lineStart = current.lastIndexOf("\n", start - 1) + 1;
const prefix = start > lineStart ? "\n" : "";
const newText = current.slice(0, start) + prefix + text + current.slice(start);
onChange(newText);
const cursorPos = start + prefix.length + text.length;
requestAnimationFrame(() => {
el.selectionStart = cursorPos;
el.selectionEnd = cursorPos;
el.focus();
});
}, [value, onChange]);
// Track active formatting at cursor position
const [cursorPos, setCursorPos] = useState<{ start: number; end: number }>({ start: 0, end: 0 });
const updateCursorPos = useCallback(() => {
const el = ref.current;
if (!el) return;
setCursorPos({ start: el.selectionStart, end: el.selectionEnd });
}, []);
const isBold = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
// Check if selection is wrapped in **
return text.slice(Math.max(0, start - 2), start) === "**" && text.slice(end, end + 2) === "**";
}
// Check if cursor is inside **...**
const before = text.slice(0, start);
const after = text.slice(start);
const lastOpen = before.lastIndexOf("**");
if (lastOpen === -1) return false;
const betweenOpen = before.slice(lastOpen + 2);
if (betweenOpen.includes("**")) return false;
return after.indexOf("**") !== -1;
}, [value, cursorPos]);
const isItalic = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
const cb = text[start - 1];
const ca = text[end];
return cb === "*" && ca === "*" && text[start - 2] !== "*" && text[end + 1] !== "*";
}
const before = text.slice(0, start);
const after = text.slice(start);
// Find single * (not **) before cursor
const lastStar = before.lastIndexOf("*");
if (lastStar === -1) return false;
if (lastStar > 0 && before[lastStar - 1] === "*") return false;
const nextStar = after.indexOf("*");
if (nextStar === -1) return false;
if (after[nextStar + 1] === "*") return false;
return true;
}, [value, cursorPos]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Use e.code for layout-independent shortcuts (works with Russian layout)
if ((e.ctrlKey || e.metaKey) && e.code === "KeyB") {
e.preventDefault();
wrapSelection("**", "**");
}
if ((e.ctrlKey || e.metaKey) && e.code === "KeyI") {
e.preventDefault();
wrapSelection("*", "*");
}
}, [wrapSelection]);
const toolbarBtn = (active: boolean) =>
`rounded p-1.5 transition-colors ${
active
? "text-gold bg-gold/15"
: "text-neutral-500 hover:text-white hover:bg-white/10"
}`;
// Preview mode: show rendered markup
if (!editing && hasContent) {
return (
{label}
{
setEditing(true);
requestAnimationFrame(() => ref.current?.focus());
}}
className="group rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative"
>
{formatMarkup(value)}
);
}
return (
{label}
{/* Toolbar */}
e.preventDefault()}
onClick={() => wrapSelection("**", "**")}
className={toolbarBtn(isBold)}
title="Жирный (Ctrl+B)"
>
e.preventDefault()}
onClick={() => wrapSelection("*", "*")}
className={toolbarBtn(isItalic)}
title="Курсив (Ctrl+I)"
>
e.preventDefault()}
onClick={() => insertAtCursor("🤍 ")}
className={toolbarBtn(false)}
title="Пункт списка"
>
e.preventDefault()}
onClick={() => wrapSelection("## ", "")}
className={toolbarBtn(false)}
title="Подзаголовок"
>
{/* Textarea */}
);
}
interface SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
hint?: string;
}
export function SelectField({
label,
value,
onChange,
options,
placeholder,
hint,
}: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef(null);
const inputRef = useRef(null);
const selectedLabel = options.find((o) => o.value === value)?.label || "";
const filtered = search
? options.filter((o) => {
const q = search.toLowerCase();
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
})
: options;
const showSearch = options.length > 3;
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
setSearch("");
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) { setOpen(true); setHighlightIndex(0); return; }
setHighlightIndex((prev) => (prev + 1) % filtered.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
}
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
e.preventDefault();
onChange(filtered[highlightIndex].value);
setOpen(false);
setSearch("");
setHighlightIndex(-1);
}
}
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
return (
{label && (
{label}
{hint && (
?
{hint}
)}
)}
{showSearch ? (
{ setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
onFocus={() => { setOpen(true); setSearch(""); }}
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
placeholder={placeholder || "Выберите..."}
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${!open && value ? "text-white" : "text-white"} placeholder-neutral-500`}
/>
) : (
setOpen(!open)}
onKeyDown={handleKeyDown}
aria-expanded={open}
aria-haspopup="listbox"
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
)}
{open && (
{filtered.length === 0 && (
Ничего не найдено
)}
{filtered.map((opt, idx) => (
e.preventDefault()}
onMouseEnter={() => setHighlightIndex(idx)}
onClick={() => {
onChange(opt.value);
setOpen(false);
setSearch("");
setHighlightIndex(-1);
inputRef.current?.blur();
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
idx === highlightIndex ? "bg-white/10" : "hover:bg-white/5"
} ${opt.value === value ? "text-gold bg-gold/5" : "text-white"}`}
>
{opt.label}
))}
)}
);
}
interface TimeRangeFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
const parts = value.split("–");
const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || "";
function update(s: string, e: string) {
if (s && e) {
onChange(`${s}–${e}`);
} else if (s) {
onChange(s);
} else {
onChange("");
}
}
function handleStartChange(newStart: string) {
if (newStart && end && newStart >= end) {
update(newStart, "");
} else {
update(newStart, end);
}
}
function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return;
update(start, newEnd);
}
return (
);
}
interface ToggleFieldProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
return (
onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? "bg-gold" : "bg-neutral-700"
}`}
>
{label}
);
}
interface ListFieldProps {
label: string;
items: string[];
onChange: (items: string[]) => void;
placeholder?: string;
}
export function ListField({ label, items, onChange, placeholder }: ListFieldProps) {
const [draft, setDraft] = useState("");
function add() {
const val = draft.trim();
if (!val) return;
onChange([...items, val]);
setDraft("");
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, value: string) {
onChange(items.map((item, i) => (i === index ? value : item)));
}
return (
{label}
{items.map((item, i) => (
update(i, e.target.value)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
/>
remove(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
))}
);
}
interface VictoryListFieldProps {
label: string;
items: RichListItem[];
onChange: (items: RichListItem[]) => void;
placeholder?: string;
onLinkValidate?: (key: string, error: string | null) => void;
onUploadComplete?: () => void;
}
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
const [draft, setDraft] = useState("");
const [uploadingIndex, setUploadingIndex] = useState(null);
function add() {
const val = draft.trim();
if (!val) return;
onChange([...items, { text: val }]);
setDraft("");
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function updateText(index: number, text: string) {
onChange(items.map((item, i) => (i === index ? { ...item, text } : item)));
}
function updateLink(index: number, link: string) {
onChange(items.map((item, i) => (i === index ? { ...item, link: link || undefined } : item)));
}
function removeImage(index: number) {
onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item)));
}
async function handleUpload(index: number, e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file) return;
setUploadingIndex(index);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
try {
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
onUploadComplete?.();
}
} catch { /* upload failed */ } finally {
setUploadingIndex(null);
}
}
return (
{label}
{items.map((item, i) => (
))}
);
}
// --- Link Field with Validation ---
interface ValidatedLinkFieldProps {
value: string;
onChange: (value: string) => void;
onValidate?: (key: string, error: string | null) => void;
validationKey?: string;
placeholder?: string;
}
export function ValidatedLinkField({ value, onChange, onValidate, validationKey, placeholder }: ValidatedLinkFieldProps) {
const [error, setError] = useState(null);
function validate(url: string) {
if (!url) {
setError(null);
onValidate?.(validationKey || "", null);
return;
}
try {
new URL(url);
setError(null);
onValidate?.(validationKey || "", null);
} catch {
setError("Некорректная ссылка");
onValidate?.(validationKey || "", "invalid");
}
}
return (
);
}
// --- 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(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 (
{label}
{ 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) => (
{item}
{ e.stopPropagation(); removeItem(item); }} className="text-gold/60 hover:text-gold transition-colors">
))}
{ 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) => (
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}
))}
)}
);
}