// =======================================================================
// MariusVPN Admin — Shared UI primitives (cards, buttons, inputs,
// toasts, modal, table, charts, icons).
// =======================================================================
const { useState, useEffect, useRef, useMemo, useCallback, createContext, useContext } = React;
// --------------- Icons (Lucide-style inline SVG) -------------------------
const Icon = ({ d, size = 18, stroke = 'currentColor', sw = 1.8, fill = 'none', className = '' }) => (
);
const ICONS = {
dashboard: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z',
users: ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', 'M22 21v-2a4 4 0 0 0-3-3.87', 'M17 3.13a4 4 0 0 1 0 7.75'],
finance: ['M12 1v22', 'M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'],
server: ['M2 3h20v6H2zM2 15h20v6H2z', 'M6 6h.01', 'M6 18h.01'],
traffic: ['M3 17l6-6 4 4 8-8', 'M14 7h7v7'],
hwid: ['M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z', 'M3.27 6.96L12 12.01l8.73-5.05', 'M12 22.08V12'],
broadcast: ['M3 11l18-8-3 18-6-7-9-3z'],
franchise: ['M3 21V7l9-4 9 4v14', 'M9 22V12h6v10'],
settings: ['M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z','M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z'],
search: ['M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z', 'M21 21l-4.35-4.35'],
plus: 'M12 5v14M5 12h14',
minus: 'M5 12h14',
close: 'M18 6L6 18M6 6l12 12',
check: 'M20 6L9 17l-5-5',
chevR: 'M9 18l6-6-6-6',
chevL: 'M15 18l-6-6 6-6',
chevD: 'M6 9l6 6 6-6',
logout: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'M16 17l5-5-5-5', 'M21 12H9'],
warn: ['M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z', 'M12 9v4', 'M12 17h.01'],
ban: ['M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z', 'M4.93 4.93l14.14 14.14'],
edit: ['M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7', 'M18.5 2.5a2.12 2.12 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z'],
trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'],
msg: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
gift: ['M20 12v10H4V12','M2 7h20v5H2z','M12 22V7','M12 7H7.5a2.5 2.5 0 1 1 0-5C11 2 12 7 12 7z','M12 7h4.5a2.5 2.5 0 1 0 0-5C13 2 12 7 12 7z'],
refresh: ['M23 4v6h-6', 'M1 20v-6h6', 'M3.51 9a9 9 0 0 1 14.85-3.36L23 10', 'M1 14l4.64 4.36A9 9 0 0 0 20.49 15'],
shield: ['M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'],
bell: ['M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9', 'M13.73 21a2 2 0 0 1-3.46 0'],
send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
link: ['M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71', 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'],
download: ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'M7 10l5 5 5-5', 'M12 15V3'],
filter: ['M22 3H2l8 9.46V19l4 2v-8.54L22 3z'],
calendar: ['M19 4H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z', 'M16 2v4', 'M8 2v4', 'M3 10h18'],
copy: ['M20 9h-9a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2z', 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'],
globe: ['M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z','M2 12h20','M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z'],
wallet: ['M21 12V7H5a2 2 0 0 1 0-4h14v4', 'M3 5v14a2 2 0 0 0 2 2h16v-5', 'M18 12a2 2 0 0 0 0 4h4v-4z'],
trending: ['M23 6l-9.5 9.5-5-5L1 18', 'M17 6h6v6'],
picture: ['M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z','M8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z','M21 15l-5-5L5 21'],
sparkles: ['M12 3l1.91 5.92L20 11l-6.09 1.91L12 19l-1.91-6.09L4 11l5.91-2.08L12 3z'],
zap: ['M13 2L3 14h9l-1 8 10-12h-9l1-8z'],
};
// --------------- Toasts ---------------------------------------------------
const ToastCtx = createContext(null);
function ToastProvider({ children }) {
const [list, setList] = useState([]);
const idRef = useRef(0);
const push = useCallback((msg, kind = 'ok') => {
const id = ++idRef.current;
setList(l => [...l, { id, msg, kind }]);
setTimeout(() => setList(l => l.filter(t => t.id !== id)), 3200);
}, []);
const api = useMemo(() => ({
ok: (m) => push(m, 'ok'),
err: (m) => push(m, 'err'),
info: (m) => push(m, 'info'),
}), [push]);
return (
{children}
{list.map(t => (
{t.kind === 'err' ? : t.kind === 'info' ? : }
{t.msg}
))}
);
}
const useToast = () => useContext(ToastCtx);
// --------------- Card / Section ------------------------------------------
const Card = ({ className = '', children, padding = 'p-5', ...rest }) => (
{children}
);
const SectionHeader = ({ title, subtitle, right }) => (
{title}
{subtitle &&
{subtitle}
}
{right &&
{right}
}
);
// --------------- Buttons -------------------------------------------------
const Button = ({ variant = 'primary', size = 'md', icon, children, className = '', loading, disabled, ...rest }) => {
const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-xl transition active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed select-none';
const sizes = { sm: 'h-8 px-3 text-[13px]', md: 'h-10 px-4 text-sm', lg: 'h-12 px-5 text-[15px]' };
const variants = {
primary: 'bg-m-accent text-[#062029] hover:bg-cyan-300',
secondary: 'bg-white/[0.04] hover:bg-white/[0.08] text-m-text border border-m-line',
ghost: 'text-m-mute hover:text-m-text hover:bg-white/[0.04]',
danger: 'bg-m-err/15 text-[#FCA5A5] hover:bg-m-err/25 border border-m-err/30',
success: 'bg-m-ok/15 text-[#86EFAC] hover:bg-m-ok/25 border border-m-ok/30',
gold: 'bg-m-gold/15 text-m-gold hover:bg-m-gold/25 border border-m-gold/30',
};
return (
);
};
const IconButton = ({ icon, onClick, title, variant = 'ghost', className = '' }) => {
const variants = {
ghost: 'text-m-mute hover:text-m-text hover:bg-white/[0.06]',
danger: 'text-m-err/80 hover:text-m-err hover:bg-m-err/10',
};
return (
);
};
// --------------- Inputs --------------------------------------------------
const fieldCls = 'w-full h-10 px-3.5 rounded-xl bg-m-bg2 border border-m-line text-m-text placeholder:text-m-dim text-sm focus:outline-none focus:border-m-accent/50 focus:bg-m-bg2 transition';
const Field = ({ label, hint, error, children }) => (
);
const Input = React.forwardRef(({ icon, className = '', ...rest }, ref) => (
{icon && }
));
const Textarea = ({ rows = 4, className = '', ...rest }) => (
);
const Select = ({ children, className = '', ...rest }) => (
);
const Switch = ({ checked, onChange, label, disabled }) => (
);
// --------------- Badges --------------------------------------------------
const Badge = ({ tone = 'mute', children, dot = false, className = '' }) => {
const tones = {
ok: 'bg-m-ok/10 text-m-ok border-m-ok/20',
err: 'bg-m-err/10 text-m-err border-m-err/20',
warn: 'bg-m-warn/10 text-m-warn border-m-warn/20',
info: 'bg-m-accent/10 text-m-accent border-m-accent/20',
mute: 'bg-white/[0.04] text-m-mute border-m-line',
gold: 'bg-m-gold/10 text-m-gold border-m-gold/20',
};
return (
{dot && }
{children}
);
};
// --------------- Modal ---------------------------------------------------
function Modal({ open, onClose, title, children, footer, width = 'max-w-lg' }) {
useEffect(() => {
if (!open) return;
const h = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
if (!open) return null;
return (
{title && (
{title}
)}
{children}
{footer &&
{footer}
}
);
}
// --------------- Side panel (right drawer) -------------------------------
function SidePanel({ open, onClose, title, children, width = 'w-[640px]' }) {
useEffect(() => {
if (!open) return;
const h = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
if (!open) return null;
return (
);
}
// --------------- Empty / Spinner / Skeleton ------------------------------
const Spinner = ({ size = 18 }) =>
;
const Loading = ({ label = 'Загрузка…' }) => (
{label}
);
const Skeleton = ({ className = '', h = 16 }) => ;
const Empty = ({ title = 'Пусто', subtitle, icon = ICONS.search }) => (
{title}
{subtitle &&
{subtitle}
}
);
// --------------- Charts (SVG) --------------------------------------------
function LineChart({ data, valueKey = 'amount', height = 200, color = '#22D3EE', formatY = (v) => v, formatX = (v) => v.slice(5) }) {
const w = 720, padL = 44, padR = 12, padT = 14, padB = 28;
if (!data || !data.length) return Нет данных
;
const vals = data.map(d => d[valueKey]);
const maxV = Math.max(...vals, 1);
const minV = 0;
const stepX = (w - padL - padR) / Math.max(1, data.length - 1);
const yScale = (v) => padT + (height - padT - padB) * (1 - (v - minV) / (maxV - minV));
const points = data.map((d, i) => [padL + i * stepX, yScale(d[valueKey])]);
const pathLine = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const pathArea = `${pathLine} L${points.at(-1)[0]},${height - padB} L${points[0][0]},${height - padB} Z`;
const yTicks = 4;
const ticks = Array.from({ length: yTicks + 1 }, (_, i) => Math.round(maxV * i / yTicks));
// x labels every Nth
const xEvery = Math.max(1, Math.ceil(data.length / 7));
const [hover, setHover] = useState(null);
const onMove = (e) => {
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const x = (e.clientX - rect.left) * (w / rect.width);
const idx = Math.max(0, Math.min(data.length - 1, Math.round((x - padL) / stepX)));
setHover(idx);
};
return (
);
}
function BarChart({ data, valueKey = 'count', height = 200, color = '#34D399', formatY = (v) => v }) {
const w = 720, padL = 36, padR = 12, padT = 14, padB = 28;
if (!data || !data.length) return Нет данных
;
const vals = data.map(d => d[valueKey]);
const maxV = Math.max(...vals, 1);
const stepX = (w - padL - padR) / data.length;
const bw = Math.max(2, stepX * 0.7);
const yScale = (v) => padT + (height - padT - padB) * (1 - v / maxV);
const yTicks = 4;
const ticks = Array.from({ length: yTicks + 1 }, (_, i) => Math.round(maxV * i / yTicks));
const xEvery = Math.max(1, Math.ceil(data.length / 7));
return (
);
}
// --------------- KPI tile ------------------------------------------------
function Kpi({ label, value, hint, accent, icon, hintTone = 'mute' }) {
const hintTones = { up: 'text-m-ok', down: 'text-m-err', mute: 'text-m-mute' };
return (
{label}
{icon && (
)}
{value}
{hint && {hint}
}
);
}
// --------------- Confirm dialog ------------------------------------------
function ConfirmDialog({ open, title, message, confirmLabel = 'Подтвердить', danger, onConfirm, onClose, loading }) {
return (
>
}
>
{message}
);
}
// --------------- Export to globals ---------------------------------------
Object.assign(window, {
Icon, ICONS,
ToastProvider, useToast,
Card, SectionHeader,
Button, IconButton,
Field, Input, Textarea, Select, Switch,
Badge, Modal, SidePanel, ConfirmDialog,
Spinner, Loading, Skeleton, Empty,
LineChart, BarChart, Kpi,
});