import { useRef, useEffect, useState } 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";
interface InputFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
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";
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 SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function SelectField({
label,
value,
onChange,
options,
placeholder,
}: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
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;
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} }
{
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
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 && (
{options.length > 3 && (
setSearch(e.target.value)}
placeholder="Поиск..."
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
/>
)}
{filtered.length === 0 && (
Ничего не найдено
)}
{filtered.map((opt) => (
{
onChange(opt.value);
setOpen(false);
setSearch("");
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors 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;
}
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: 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)));
}
} catch { /* upload failed */ } finally {
setUploadingIndex(null);
}
}
return (
{label}
{items.map((item, i) => (
))}
);
}
// --- Date Range Picker ---
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
function parseDateRange(value: string): { start: string; end: string } {
if (!value) return { start: "", end: "" };
// "22-23.02.2025" → same month range
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (sameMonth) {
const [, d1, d2, m, y] = sameMonth;
return {
start: `${y}-${m}-${d1.padStart(2, "0")}`,
end: `${y}-${m}-${d2.padStart(2, "0")}`,
};
}
// "22.02-01.03.2025" → cross-month range
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (crossMonth) {
const [, d1, m1, d2, m2, y] = crossMonth;
return {
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
};
}
// "22.02.2025" → single date
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (single) {
const [, d, m, y] = single;
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
return { start: iso, end: "" };
}
return { start: "", end: "" };
}
function formatDateRange(start: string, end: string): string {
if (!start) return "";
const [sy, sm, sd] = start.split("-");
if (!end) return `${sd}.${sm}.${sy}`;
const [ey, em, ed] = end.split("-");
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
return `${sd}.${sm}-${ed}.${em}.${ey}`;
}
interface DateRangeFieldProps {
value: string;
onChange: (value: string) => void;
}
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
const { start, end } = parseDateRange(value);
function handleChange(s: string, e: string) {
onChange(formatDateRange(s, e));
}
return (
handleChange(e.target.value, end)}
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
—
handleChange(start, e.target.value)}
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
);
}
// --- City Autocomplete Field ---
interface CityFieldProps {
value: string;
onChange: (value: string) => void;
error?: string;
onSearch?: (query: string) => void;
suggestions?: string[];
onSelectSuggestion?: (value: string) => void;
}
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
const [focused, setFocused] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
if (!focused) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setFocused(false);
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [focused]);
return (
{error &&
{error}
}
{focused && suggestions && suggestions.length > 0 && (
{suggestions.map((s) => (
e.preventDefault()}
onClick={() => {
onSelectSuggestion?.(s);
setFocused(false);
}}
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
>
{s}
))}
)}
);
}
// --- 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 (
);
}
interface VictoryItemListFieldProps {
label: string;
items: VictoryItem[];
onChange: (items: VictoryItem[]) => void;
cityErrors?: Record;
citySuggestions?: { index: number; items: string[] } | null;
onCitySearch?: (index: number, query: string) => void;
onCitySelect?: (index: number, value: string) => void;
onLinkValidate?: (key: string, error: string | null) => void;
}
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
function add() {
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, field: keyof VictoryItem, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
}
return (
{label}
{items.map((item, i) => (
update(i, "type", e.target.value)}
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
>
Место
Номинация
Судейство
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"
/>
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"
/>
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"
/>
remove(i)}
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
>
update(i, "location", v)}
error={cityErrors?.[i]}
onSearch={(q) => onCitySearch?.(i, q)}
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
/>
update(i, "date", v)}
/>
update(i, "link", v)}
validationKey={`victory-${i}`}
onValidate={onLinkValidate}
/>
))}
Добавить достижение
);
}