// 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 (

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 (
);
}
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 (
);
}
/* ============================
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,
});