22bd117dae
- RichTextarea with toolbar (Bold, Italic, List, Heading) + Ctrl+B/I
hotkeys (layout-independent), active state highlighting, preview mode
- Shared ImageCropField component (replaces duplicate in news/classes)
with drag-to-reposition, Ctrl+scroll zoom, compact layout
- SectionEditor defaultData prop — all admin pages handle empty DB
- Team: section title editable, toast notifications, unsaved data warning
on navigation (back button, sidebar links, browser close)
- Carousel: continuous card wrapping during drag, edge fade for small teams
- Markup renderer: **bold**, *italic*, ## headings, 🤍 bullet points
- Empty DB guards on all public site sections
- Fix: upload error handling, contact phone field, "team" section key
995 lines
34 KiB
TypeScript
995 lines
34 KiB
TypeScript
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 (
|
||
<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={inputCls}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
|
||
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
|
||
aria-describedby="min-hint"
|
||
aria-invalid={minEmpty || undefined}
|
||
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
|
||
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
|
||
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
|
||
aria-describedby="max-hint"
|
||
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
|
||
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
|
||
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
|
||
</p>
|
||
</div>
|
||
</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={textAreaInput}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLTextAreaElement>(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 (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div
|
||
onClick={() => {
|
||
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"
|
||
>
|
||
<div className="text-sm leading-relaxed text-neutral-300">
|
||
{formatMarkup(value)}
|
||
</div>
|
||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<span className="flex items-center gap-1 text-xs text-neutral-500">
|
||
<Pencil size={10} />
|
||
редактировать
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="rounded-lg border border-white/10 bg-neutral-800 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-white/5">
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => wrapSelection("**", "**")}
|
||
className={toolbarBtn(isBold)}
|
||
title="Жирный (Ctrl+B)"
|
||
>
|
||
<Bold size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => wrapSelection("*", "*")}
|
||
className={toolbarBtn(isItalic)}
|
||
title="Курсив (Ctrl+I)"
|
||
>
|
||
<Italic size={14} />
|
||
</button>
|
||
<div className="w-px h-4 bg-white/10 mx-1" />
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => insertAtCursor("🤍 ")}
|
||
className={toolbarBtn(false)}
|
||
title="Пункт списка"
|
||
>
|
||
<List size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => wrapSelection("## ", "")}
|
||
className={toolbarBtn(false)}
|
||
title="Подзаголовок"
|
||
>
|
||
<Heading2 size={14} />
|
||
</button>
|
||
</div>
|
||
{/* Textarea */}
|
||
<textarea
|
||
ref={ref}
|
||
value={value ?? ""}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onKeyUp={updateCursorPos}
|
||
onClick={updateCursorPos}
|
||
onSelect={updateCursorPos}
|
||
onBlur={() => setEditing(false)}
|
||
placeholder={placeholder}
|
||
rows={rows}
|
||
className="w-full bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<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;
|
||
|
||
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 (
|
||
<div ref={containerRef} className="relative">
|
||
{label && (
|
||
<label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
|
||
{label}
|
||
{hint && (
|
||
<span className="group relative">
|
||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
|
||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity">
|
||
{hint}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</label>
|
||
)}
|
||
{showSearch ? (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={open ? search : selectedLabel}
|
||
onChange={(e) => { 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`}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => 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 || "Выберите..."}
|
||
</button>
|
||
)}
|
||
|
||
{open && (
|
||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||
<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, idx) => (
|
||
<button
|
||
key={opt.value || `opt-${idx}`}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={opt.value === value}
|
||
onMouseDown={(e) => 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}
|
||
</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(); } }}
|
||
onBlur={add}
|
||
placeholder={placeholder || "Добавить..."}
|
||
className={dashedInput}
|
||
/>
|
||
<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;
|
||
onUploadComplete?: () => void;
|
||
}
|
||
|
||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: 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 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 (
|
||
<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 transition-colors hover:border-gold/30 hover:bg-neutral-800/80 focus-within:border-gold/50 focus-within:bg-neutral-800">
|
||
<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(); } }}
|
||
onBlur={add}
|
||
placeholder={placeholder || "Добавить..."}
|
||
className={dashedInput}
|
||
/>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// --- 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>
|
||
);
|
||
}
|
||
|
||
// --- 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>
|
||
);
|
||
}
|