Add type field to VictoryItem, tabbed UI in trainer profile, and type dropdown in admin form. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
763 lines
26 KiB
TypeScript
763 lines
26 KiB
TypeScript
import { useRef, useEffect, useState } from "react";
|
||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||
|
||
interface InputFieldProps {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
type?: "text" | "url" | "tel";
|
||
}
|
||
|
||
export function InputField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
type = "text",
|
||
}: InputFieldProps) {
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<input
|
||
type={type}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
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"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLTextAreaElement>(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 (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<textarea
|
||
ref={ref}
|
||
value={value}
|
||
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"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLInputElement>(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 (
|
||
<div ref={containerRef} className="relative">
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setOpen(!open);
|
||
setSearch("");
|
||
setTimeout(() => inputRef.current?.focus(), 0);
|
||
}}
|
||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-left outline-none transition-colors ${
|
||
open ? "border-gold" : "border-white/10"
|
||
} ${value ? "text-white" : "text-neutral-500"}`}
|
||
>
|
||
{selectedLabel || placeholder || "Выберите..."}
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||
{options.length > 3 && (
|
||
<div className="p-1.5">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="max-h-48 overflow-y-auto">
|
||
{filtered.length === 0 && (
|
||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||
)}
|
||
{filtered.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => {
|
||
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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="time"
|
||
value={start}
|
||
onChange={(e) => handleStartChange(e.target.value)}
|
||
onBlur={onBlur}
|
||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||
/>
|
||
<span className="text-neutral-500">–</span>
|
||
<input
|
||
type="time"
|
||
value={end}
|
||
onChange={(e) => handleEndChange(e.target.value)}
|
||
onBlur={onBlur}
|
||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ToggleFieldProps {
|
||
label: string;
|
||
checked: boolean;
|
||
onChange: (checked: boolean) => void;
|
||
}
|
||
|
||
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
|
||
return (
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||
checked ? "bg-gold" : "bg-neutral-700"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||
checked ? "translate-x-5" : ""
|
||
}`}
|
||
/>
|
||
</button>
|
||
<span className="text-sm text-neutral-300">{label}</span>
|
||
</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 (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="space-y-2">
|
||
{items.map((item, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={item}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(i)}
|
||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={draft}
|
||
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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={add}
|
||
disabled={!draft.trim()}
|
||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||
>
|
||
<Plus size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<number | null>(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<HTMLInputElement>) {
|
||
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 fetch("/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 (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="space-y-2">
|
||
{items.map((item, i) => (
|
||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||
<div className="flex items-center gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={item.text}
|
||
onChange={(e) => updateText(i, e.target.value)}
|
||
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(i)}
|
||
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{item.image ? (
|
||
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
||
<ImageIcon size={10} className="text-gold" />
|
||
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
||
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
||
<X size={9} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<label className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors">
|
||
{uploadingIndex === i ? <Loader2 size={10} className="animate-spin" /> : <Upload size={10} />}
|
||
{uploadingIndex === i ? "..." : "Фото"}
|
||
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
|
||
</label>
|
||
)}
|
||
<ValidatedLinkField
|
||
value={item.link || ""}
|
||
onChange={(v) => updateLink(i, v)}
|
||
validationKey={`edu-${i}`}
|
||
onValidate={onLinkValidate}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={draft}
|
||
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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={add}
|
||
disabled={!draft.trim()}
|
||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||
>
|
||
<Plus size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 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 (
|
||
<div className="flex items-center gap-1">
|
||
<Calendar size={11} className="text-neutral-500 shrink-0" />
|
||
<input
|
||
type="date"
|
||
value={start}
|
||
onChange={(e) => 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]"
|
||
/>
|
||
<span className="text-neutral-500 text-xs">—</span>
|
||
<input
|
||
type="date"
|
||
value={end}
|
||
min={start}
|
||
onChange={(e) => 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]"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 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<HTMLDivElement>(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 (
|
||
<div ref={containerRef} className="relative flex-1">
|
||
<div className="relative">
|
||
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => {
|
||
onChange(e.target.value);
|
||
onSearch?.(e.target.value);
|
||
}}
|
||
onFocus={() => setFocused(true)}
|
||
placeholder="Город, страна"
|
||
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
|
||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||
}`}
|
||
/>
|
||
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
|
||
</div>
|
||
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
|
||
{focused && suggestions && suggestions.length > 0 && (
|
||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||
{suggestions.map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onMouseDown={(e) => 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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 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<string | null>(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 (
|
||
<div className="flex items-center gap-1.5 flex-1">
|
||
<Link size={12} className="text-neutral-500 shrink-0" />
|
||
<div className="relative flex-1">
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => {
|
||
onChange(e.target.value);
|
||
validate(e.target.value);
|
||
}}
|
||
placeholder={placeholder || "Ссылка..."}
|
||
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
|
||
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
|
||
}`}
|
||
/>
|
||
{error && (
|
||
<span className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
||
<AlertCircle size={10} className="text-red-400" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface VictoryItemListFieldProps {
|
||
label: string;
|
||
items: VictoryItem[];
|
||
onChange: (items: VictoryItem[]) => void;
|
||
cityErrors?: Record<number, string>;
|
||
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 (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="space-y-3">
|
||
{items.map((item, i) => (
|
||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||
<div className="flex gap-1.5">
|
||
<select
|
||
value={item.type || "place"}
|
||
onChange={(e) => 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"
|
||
>
|
||
<option value="place">Место</option>
|
||
<option value="nomination">Номинация</option>
|
||
<option value="judge">Судейство</option>
|
||
</select>
|
||
<input
|
||
type="text"
|
||
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"
|
||
/>
|
||
<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"
|
||
/>
|
||
<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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(i)}
|
||
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<CityField
|
||
value={item.location || ""}
|
||
onChange={(v) => update(i, "location", v)}
|
||
error={cityErrors?.[i]}
|
||
onSearch={(q) => onCitySearch?.(i, q)}
|
||
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
|
||
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
|
||
/>
|
||
<DateRangeField
|
||
value={item.date || ""}
|
||
onChange={(v) => update(i, "date", v)}
|
||
/>
|
||
</div>
|
||
<ValidatedLinkField
|
||
value={item.link || ""}
|
||
onChange={(v) => update(i, "link", v)}
|
||
validationKey={`victory-${i}`}
|
||
onValidate={onLinkValidate}
|
||
/>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={add}
|
||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||
>
|
||
<Plus size={14} />
|
||
Добавить достижение
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|