// components.jsx — shared primitives (Card, Pill, Avatar, Metric, Toggle, ScrollList, etc.) const { useState, useMemo, useRef, useEffect, useLayoutEffect } = React; function Card({ children, className, style }) { return (
{children}
); } function SectionTitle({ title, subtitle, action, children }) { return (

{title}

{subtitle &&

{subtitle}

}
{action || children}
); } function Avatar({ name, color, avatarUrl, size = 36, ring = false }) { // Если передан avatarUrl — рендерим с native graceful-fallback: // при load-error возвращаемся к инициалам на градиенте. Это даёт // прозрачный degradation если бэкенд вернул 404. const [imgFailed, setImgFailed] = useState(false); const initials = (name || "?").slice(0, 2).toUpperCase(); const px = size + "px"; const ringCls = ring ? "ring-2 ring-zinc-900" : ""; if (avatarUrl && !imgFailed) { return (
{name setImgFailed(true)} className="w-full h-full object-cover" />
); } return (
{initials}
); } const PILL_TONES = { emerald: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", rose: "bg-rose-500/10 text-rose-400 border-rose-500/20", amber: "bg-amber-500/10 text-amber-400 border-amber-500/20", indigo: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", fuchsia: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20", sky: "bg-sky-500/10 text-sky-400 border-sky-500/20", zinc: "bg-zinc-800/60 text-zinc-400 border-zinc-700/60", }; function Pill({ children, tone = "zinc", className = "" }) { return ( {children} ); } function Metric({ label, value, delta, deltaPositive, sublabel, accent }) { return (
{label}
{value}
{delta != null && (
{delta}
)}
{sublabel &&
{sublabel}
}
); } function Toggle({ on, onChange, size = "md" }) { const sz = size === "sm" ? { w: 28, h: 16, d: 12 } : { w: 36, h: 20, d: 16 }; return ( ); } function IconButton({ icon, tone = "zinc", onClick, label, active }) { const tones = { zinc: "bg-zinc-800/60 border-zinc-700/60 hover:bg-zinc-800 text-zinc-300", indigo: "bg-indigo-500 hover:bg-indigo-600 border-indigo-500 text-white", emerald: "bg-emerald-500/10 hover:bg-emerald-500/20 border-emerald-500/30 text-emerald-400", rose: "bg-rose-500/10 hover:bg-rose-500/20 border-rose-500/30 text-rose-400", ghost: "bg-transparent border-transparent hover:bg-zinc-800/60 text-zinc-400 hover:text-zinc-200", }; return ( ); } function SegmentedControl({ value, onChange, options, size = "md" }) { const pad = size === "sm" ? "px-2.5 py-1 text-[11px]" : "px-3 py-1.5 text-xs"; return (
{options.map((o) => { const isActive = value === o.id; return ( ); })}
); } function Field({ label, value, defaultValue, placeholder, mono, type = "text", onChange, suffix }) { return (
{label}
{suffix &&
{suffix}
}
); } function SearchInput({ value, onChange, placeholder = "Поиск…", autoFocus, kbd }) { return (
onChange(e.target.value)} placeholder={placeholder} autoFocus={autoFocus} className="w-full bg-zinc-900/60 border border-zinc-800 rounded-lg pl-9 pr-12 py-2 text-sm placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50" /> {kbd && ( {kbd} )}
); } /* ============================ ScrollList with custom thumb ============================ */ function ScrollList({ children }) { const ref = useRef(null); const [s, setS] = useState({ top: 0, max: 0, visible: 1 }); const [drag, setDrag] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const update = () => { setS({ top: el.scrollTop, max: el.scrollHeight - el.clientHeight, visible: el.clientHeight / el.scrollHeight, }); }; update(); el.addEventListener("scroll", update); const ro = new ResizeObserver(update); ro.observe(el); const mo = new MutationObserver(update); mo.observe(el, { childList: true, subtree: true }); return () => { el.removeEventListener("scroll", update); ro.disconnect(); mo.disconnect(); }; }); const needsScroll = s.max > 1; const onTrackDown = (e) => { if (!ref.current) return; const track = e.currentTarget; const trackRect = track.getBoundingClientRect(); const thumbH = trackRect.height * s.visible; const moveTo = (clientY) => { const y = clientY - trackRect.top - thumbH / 2; const ratio = Math.max(0, Math.min(1, y / (trackRect.height - thumbH))); ref.current.scrollTop = ratio * s.max; }; moveTo(e.clientY); setDrag(true); const onMove = (ev) => moveTo(ev.clientY); const onUp = () => { setDrag(false); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }; return (
{children}
{needsScroll && (
)}
); } /* ============================ Empty state ============================ */ function EmptyState({ icon, title, hint, action }) { return (
{title}
{hint &&
{hint}
} {action &&
{action}
}
); } /* ============================ Confidence bar (for AI tags / check verification) ============================ */ function ConfidenceBar({ value, tone = "indigo" }) { const pct = Math.round(value * 100); const tones = { indigo: "bg-indigo-400", emerald: "bg-emerald-400", amber: "bg-amber-400", rose: "bg-rose-400", }; const t = pct >= 90 ? "emerald" : pct >= 70 ? "indigo" : pct >= 50 ? "amber" : "rose"; return (
{pct}%
); } /* ============================ Kbd ============================ */ function Kbd({ children }) { return ( {children} ); } Object.assign(window, { Card, SectionTitle, Avatar, Pill, Metric, Toggle, IconButton, SegmentedControl, Field, SearchInput, ScrollList, EmptyState, ConfidenceBar, Kbd, PILL_TONES, });