/* global React */
// Postilo app-specific components (Russian UI) — builds on Vuexy primitives.
const POSTILO_ICONS = {
"chart-pie": "M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-7a.9 .9 0 0 0 -1 -.8M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1z",
"hash": "M5 9l14 0M5 15l14 0M11 4l-4 16M17 4l-4 16",
"repeat": "M17 2l4 4l-4 4M3 11v-1a4 4 0 0 1 4 -4h14M7 22l-4 -4l4 -4M21 13v1a4 4 0 0 1 -4 4h-14",
"shield-check": "M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06M15 19l2 2l4 -4",
"trash": "M4 7l16 0M10 11l0 6M14 11l0 6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
"calendar-event": "M4 5m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2zM16 3v4M8 3v4M4 11h16M8 15h2v2h-2z",
"brand-telegram": "M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4",
"circle-check": "M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0M9 12l2 2l4 -4",
"refresh": "M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4",
"eye": "M12 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0M22 12c-2.667 4.667 -6 7 -10 7s-7.333 -2.333 -10 -7c2.667 -4.667 6 -7 10 -7s7.333 2.333 10 7",
"lock": "M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2zM11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0M8 11v-4a4 4 0 1 1 8 0v4",
"users-group": "M10 13a2 2 0 1 0 -4 0a2 2 0 0 0 4 0M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1M15 5a2 2 0 1 0 -4 0a2 2 0 0 0 4 0M17 10v-1a2 2 0 0 0 -2 -2h-4a2 2 0 0 0 -2 2v1M21 13a2 2 0 1 0 -4 0a2 2 0 0 0 4 0",
"users": "M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0 -3 -3.85",
"filter": "M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227z",
"chevron-down": "M6 9l6 6l6 -6",
"chevron-right": "M9 6l6 6l-6 6",
"chevron-left": "M15 6l-6 6l6 6",
"x": "M18 6l-12 12M6 6l12 12",
"sparkles": "M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zM16 6a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zM9 18a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z",
"menu-2": "M4 6h16M4 12h16M4 18h16",
"adjustments": "M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0M6 4v4M6 12v8M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0M12 4v10M12 18v2M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0M18 4v1M18 9v11",
"plus": "M12 5l0 14M5 12l14 0",
"clock": "M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0M12 7v5l3 3",
"settings": "M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06 .06a2 2 0 0 1 0 2.83a2 2 0 0 1 -2.83 0l-.06 -.06a1.65 1.65 0 0 0 -1.82 -.33a1.65 1.65 0 0 0 -1 1.51v.17a2 2 0 0 1 -2 2a2 2 0 0 1 -2 -2v-.09a1.65 1.65 0 0 0 -1 -1.51a1.65 1.65 0 0 0 -1.82 .33l-.06 .06a2 2 0 0 1 -2.83 0a2 2 0 0 1 0 -2.83l.06 -.06a1.65 1.65 0 0 0 .33 -1.82a1.65 1.65 0 0 0 -1.51 -1h-.17a2 2 0 0 1 -2 -2a2 2 0 0 1 2 -2h.09a1.65 1.65 0 0 0 1.51 -1a1.65 1.65 0 0 0 -.33 -1.82l-.06 -.06a2 2 0 0 1 0 -2.83a2 2 0 0 1 2.83 0l.06 .06a1.65 1.65 0 0 0 1.82 .33h0a1.65 1.65 0 0 0 1 -1.51v-.17a2 2 0 0 1 2 -2a2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82 -.33l.06 -.06a2 2 0 0 1 2.83 0a2 2 0 0 1 0 2.83l-.06 .06a1.65 1.65 0 0 0 -.33 1.82v0a1.65 1.65 0 0 0 1.51 1h.17a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-.09a1.65 1.65 0 0 0 -1.51 1z",
"help-circle": "M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0M12 16v.01M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483",
"bell": "M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6M9 17v1a3 3 0 0 0 6 0v-1",
"dots-vertical": "M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M12 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M12 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
"search": "M21 21l-6 -6M3 10a7 7 0 1 0 14 0a7 7 0 0 0 -14 0",
"send": "M10 14l11 -11M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -8l-8 -3.5a.55 .55 0 0 1 0 -1z",
"photo": "M15 8h.01M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3zM3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3",
"alert-triangle": "M12 9v4M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0zM12 16h.01",
"chevron-up": "M6 15l6 -6l6 6",
"user": "M12 11m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2",
"logout": "M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2M9 12h12l-3 -3M18 15l3 -3",
"info-circle": "M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0M12 9h.01M11 12h1v4h1",
"key": "M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0zM15 9h.01",
"monitor": "M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1zM7 20h10M9 16v4M15 16v4",
"upload": "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2M7 9l5 -5l5 5M12 4v12",
"download": "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2M7 11l5 5l5 -5M12 4v12",
"plug": "M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054zM4 20l3.5 -3.5M15 4l-3.5 3.5M20 9l-3.5 3.5",
"arrow-down": "M12 5v14M18 13l-6 6M6 13l6 6",
"arrow-right": "M5 12l14 0M13 18l6 -6M13 6l6 6",
"external-link": "M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6M11 13l9 -9M15 4h5v5",
"file-text": "M14 3v4a1 1 0 0 0 1 1h4M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2zM9 9h1M9 13h6M9 17h6",
"code": "M7 8l-4 4l4 4M17 8l4 4l-4 4M14 4l-4 16",
"credit-card": "M3 5m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3zM3 10l18 0M7 15l.01 0M11 15l2 0",
"home": "M5 12l-2 0l9 -9l9 9l-2 0M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6",
};
const TIP = ({ n, size = 18, color, strokeWidth = 2 }) => {
const d = POSTILO_ICONS[n];
if (d) {
return (
);
}
return ;
};
function PostiloMark({ size = 32, gradientFrom = "#7367F0", gradientTo = "#8F85F3" }) {
return (
{options.map(o => {
const active = o.value === value;
return (
);
})}
);
}
function ChannelAvatar({ initials, color, platform, size = 48, radius = 10, avatar }) {
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
style={{ transition: "background .12s", cursor: "pointer", background: bg }}
>
{displayTitle}
{ch.platform === "max"
? (ch.chatId || (ch.id ? String(ch.id) : ""))
: (ch.username ? `@${ch.username}` : "")}
|
e.stopPropagation()}>
{mappings.length > 0 ? (
{visibleMappings.map((m) => {
const isHover = pillHoverId === m.id;
return (
);
})}
{hiddenCount > 0 && !mappingsExpanded && (
)}
{mappingsExpanded && mappings.length > MAPPING_VISIBLE && (
)}
) : (
)}
|
{ch.subscribers}
|
{renderGrowth(ch.growth?.today, ch.spark?.today || ch.activity)} |
{renderGrowth(ch.growth?.week, ch.spark?.week || ch.activity)} |
{renderGrowth(ch.growth?.month, ch.spark?.month || ch.activity)} |
{ch.views24h != null ? formatCompact(ch.views24h) : "—"}
|
e.stopPropagation()}>
}>
{ window.location.href = `/channel/${ch.id}`; }}>Открыть канал
{ window.location.href = `/channel/${ch.id}?action=create-mapping`; }}>Создать связку
{ window.location.href = `/channel/${ch.id}?tab=settings`; }}>Настройки
{ if (window.confirm(`Удалить канал «${ch.title}» из Postilo?`)) alert("Удаление (демо)"); }}>Удалить канал
|
);
}
// ==================== SHARED SHELL ====================
const DEFAULT_NOTIFICATIONS = [
{ id: 1, kind: "error", icon: "alert-triangle", iconBg: "rgba(255,76,81,.14)", iconFg: "#FF4C51",
title: "Crypto Insights — ошибка репоста",
desc: "MAX API вернул 429 Too Many Requests. Повторная попытка через 5 мин.",
time: "5 мин назад", unread: true, href: "/channel/6" },
{ id: 2, kind: "success", icon: "circle-check", iconBg: "rgba(40,199,111,.14)", iconFg: "#28C76F",
title: "Новый канал подключён",
desc: "Daily Deals успешно синхронизирован и готов к работе.",
time: "1 ч назад", unread: true, href: "/channel/10" },
{ id: 3, kind: "info", icon: "repeat", iconBg: "rgba(115,103,240,.14)", iconFg: "#7367F0",
title: "48 репостов за сутки",
desc: "Рекорд за последние 30 дней. Посмотреть статистику?",
time: "6 ч назад", unread: false, href: "/calendar" },
{ id: 4, kind: "warning", icon: "shield-check", iconBg: "rgba(255,159,67,.14)", iconFg: "#FF9F43",
title: "Антиспам сработал 12 раз",
desc: "За сегодня во всех каналах. 3 флуда, 7 ссылок, 2 бота.",
time: "вчера", unread: false, href: "/channel/1#antispam" },
{ id: 5, kind: "info", icon: "brand-telegram", iconBg: "rgba(34,158,217,.14)", iconFg: "#229ED9",
title: "Бот обновлён",
desc: "Postilo v2.4 — улучшенная обработка медиа-групп.",
time: "2 дня назад", unread: false, href: "/settings" },
];
function NotificationBell({ size = 34, placement = "auto" }) {
const [open, setOpen] = React.useState(false);
const [items, setItems] = React.useState(DEFAULT_NOTIFICATIONS);
const [flipUp, setFlipUp] = React.useState(false);
const [anchorLeft, setAnchorLeft] = React.useState(false);
const ref = React.useRef();
const unread = items.filter(n => n.unread).length;
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
// spec 004 — live fetch errors из /api/log_notifications. Preview-mode → mock.
React.useEffect(() => {
const apiKey = (typeof localStorage !== "undefined") ? (localStorage.getItem("api_key") || "") : "";
if (!apiKey || !window.INITIAL_DATA) return;
fetch("/api/log_notifications?limit=10", { headers: { "X-API-Key": apiKey } })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(d => {
if (!Array.isArray(d.items)) return;
setItems(d.items.map(it => ({
id: it.id,
kind: "error",
icon: "alert-triangle",
iconBg: "rgba(255,76,81,.14)",
iconFg: "#FF4C51",
title: `Ошибка репоста: ${it.mapping_label || `Связка #${it.mapping_id}`}`,
desc: it.error_text || "Без описания",
time: it.created_at ? String(it.created_at).slice(5, 16) : "—",
unread: true,
href: "/logs",
})));
})
.catch(e => console.error("[design-v2] NotificationBell fetch failed:", e));
}, []);
// Auto-detect vertical flip + horizontal anchor based on viewport space
React.useEffect(() => {
if (!open || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
const dropdownWidth = 360;
const dropdownHeight = 480;
// Vertical: flip upward if not enough space below
if (placement === "auto") {
const spaceBelow = window.innerHeight - rect.bottom;
setFlipUp(spaceBelow < dropdownHeight && rect.top > spaceBelow);
}
// Horizontal: pick side with less overflow.
// right-anchored: dropdown spans [rect.right - W, rect.right]
// left-anchored: dropdown spans [rect.left, rect.left + W]
const overflowWithRight = Math.max(0, 8 - (rect.right - dropdownWidth));
const overflowWithLeft = Math.max(0, (rect.left + dropdownWidth) - (window.innerWidth - 8));
setAnchorLeft(overflowWithLeft < overflowWithRight);
}, [open, placement]);
const dropUp = placement === "up" || (placement === "auto" && flipUp);
const markAllRead = () => setItems(items.map(n => ({ ...n, unread: false })));
return (
setMobileOpen(false)} className="sidebar-backdrop" style={{
position: "fixed", inset: 0, zIndex: 48,
background: "rgba(47,43,61,.48)", backdropFilter: "blur(2px)",
animation: "fadeIn .15s linear",
}} />
)}
>
);
}
function UserMenu({ user, accent = "#7367F0" }) {
const [open, setOpen] = React.useState(false);
const [logout, setLogout] = React.useState(false);
const ref = React.useRef();
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const itemSt = {
display: "flex", alignItems: "center", gap: 10, width: "100%",
padding: "9px 12px", background: "transparent", border: "none",
borderRadius: 4, cursor: "pointer", textAlign: "left",
fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 400,
color: "var(--text-primary)", textDecoration: "none",
};
return (
{open && (
{user.name}
admin@postilo.app
e.currentTarget.style.background = "rgba(47,43,61,.05)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
Профиль
e.currentTarget.style.background = "rgba(47,43,61,.05)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
Настройки
e.currentTarget.style.background = "rgba(47,43,61,.05)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
Справка
)}
{logout &&
setLogout(false)} />}
);
}
function LogoutModal({ onClose }) {
return (
e.stopPropagation()} style={{
width: "100%", maxWidth: 420,
background: "#fff", borderRadius: 6,
boxShadow: "0 24px 48px -12px rgba(47,43,61,.32), 0 0 0 1px rgba(47,43,61,.06)",
overflow: "hidden",
animation: "modalIn .22s cubic-bezier(0.4,0,0.2,1)",
}}>
Выйти из аккаунта?
Репосты продолжат работать в фоне. Вы сможете войти снова в любой момент.
);
}
function SidebarLink({ item, active, onNavigate, accent }) {
const [hover, setHover] = React.useState(false);
const clickHandler = item.href ? undefined : (e) => { if (onNavigate) onNavigate(item.id); };
return (
setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: "flex", alignItems: "center", gap: 12,
padding: "10px 14px", borderRadius: 8, cursor: "pointer",
color: active ? "#fff" : "var(--text-primary)",
background: active ? `linear-gradient(270deg, ${accent} 0%, ${accent}cc 100%)`
: hover ? "rgba(47,43,61,0.06)" : "transparent",
boxShadow: active ? `0 2px 4px ${accent}4D` : "none",
marginBottom: 2, transition: "background .15s, color .15s",
fontSize: 15, lineHeight: "22px", fontWeight: active ? 500 : 400,
textDecoration: "none",
}}>
{item.label}
{item.count != null && (
{item.count}
)}
{item.badge && (
{item.badge}
)}
);
}
function PageHeader({ title, subtitle, right, children }) {
return (
{title}
{subtitle &&
{subtitle}
}
{children}
{right &&
{right}
}
);
}
// Inline stats row without card wrappers — matches Vuexy Academy / Learning dashboard pattern
// (icon + label + value + delta inline, no white plashkas, dividers between items)
function StatsInline({ items, marginBottom = 24 }) {
return (
{items.map((s, i) => (
{s.label}
{s.value}
{s.delta &&
{s.delta}
}
))}
);
}
// Shared Dropdown — click-outside closes, Vuexy 6px radius
function Dropdown({ trigger, children, width = 220, align = "right" }) {
const [open, setOpen] = React.useState(false);
const [coords, setCoords] = React.useState(null);
const ref = React.useRef();
const triggerRef = React.useRef();
const compute = React.useCallback(() => {
const tr = triggerRef.current;
if (!tr) return;
const r = tr.getBoundingClientRect();
const estHeight = 280;
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const flipUp = spaceBelow < estHeight && spaceAbove > spaceBelow;
const top = flipUp ? Math.max(8, r.top - estHeight - 4) : r.bottom + 4;
const left = align === "right" ? Math.max(8, r.right - width) : Math.min(window.innerWidth - width - 8, r.left);
setCoords({ top, left, flipUp });
}, [align, width]);
React.useEffect(() => {
if (!open) return;
compute();
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
const onScroll = () => setOpen(false);
document.addEventListener("mousedown", handler);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", compute);
return () => {
document.removeEventListener("mousedown", handler);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", compute);
};
}, [open, compute]);
return (
<>
setOpen(!open)}>{trigger}
{open && coords && (
setOpen(false)}>{children}
)}
>
);
}
function DropdownItem({ icon, children, onClick, danger, active }) {
const [hover, setHover] = React.useState(false);
return (
);
}
function DropdownDivider() {
return
;
}
// Channel picker — dropdown select with channel list + platform glyphs
function ChannelPicker({ value, onChange, channels, placeholder = "Выбрать канал", width = "100%", searchable = true }) {
const selected = channels.find(c => c.id === value);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [coords, setCoords] = React.useState({ top: 0, left: 0, width: 0, flipUp: false });
const ref = React.useRef();
const searchRef = React.useRef();
React.useEffect(() => {
if (!open) return;
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
// Auto-focus search when открывается
if (searchable && searchRef.current) {
setTimeout(() => searchRef.current && searchRef.current.focus(), 30);
}
return () => document.removeEventListener("mousedown", handler);
}, [open, searchable]);
// Position dropdown via fixed coords (избегает clipping в overflow:hidden модалках).
React.useEffect(() => {
if (!open || !ref.current) return;
const compute = () => {
const r = ref.current.getBoundingClientRect();
const dropH = 360; // approx max
const spaceBelow = window.innerHeight - r.bottom;
const flipUp = spaceBelow < dropH && r.top > spaceBelow;
setCoords({ top: flipUp ? r.top - 4 : r.bottom + 4, left: r.left, width: r.width, flipUp });
};
compute();
window.addEventListener("scroll", compute, true);
window.addEventListener("resize", compute);
return () => {
window.removeEventListener("scroll", compute, true);
window.removeEventListener("resize", compute);
};
}, [open]);
React.useEffect(() => { if (!open) setQuery(""); }, [open]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return channels;
return channels.filter(ch =>
String(ch.title || "").toLowerCase().includes(q) ||
String(ch.username || "").toLowerCase().includes(q)
);
}, [query, channels]);
return (
setOpen(!open)} style={{
display: "flex", alignItems: "center", gap: 10, padding: "10px 12px",
background: "#fff", border: `1px solid ${open ? "#7367F0" : "var(--input-border)"}`,
borderRadius: 6, cursor: "pointer",
boxShadow: open ? "0 0 0 3px rgba(115,103,240,.16)" : "none",
transition: "border-color .12s, box-shadow .12s",
}}>
{selected ? (
<>
{selected.title}
{selected.username &&
@{selected.username}
}
>
) : (
{placeholder}
)}
{open && (
{searchable && channels.length > 4 && (
setQuery(e.target.value)}
placeholder="Поиск канала…"
style={{
width: "100%", padding: "8px 10px", fontSize: 13,
border: "1px solid #7367F0", borderRadius: 6, outline: "none",
fontFamily: "var(--font-sans)", color: "var(--text-primary)",
boxShadow: "0 0 0 3px rgba(115,103,240,.10)",
boxSizing: "border-box",
}}
/>
)}
{filtered.length === 0 ? (
Ничего не найдено
) : filtered.map(ch => {
const isActive = ch.id === value;
return (
{ onChange(ch.id); setOpen(false); }}
style={{
display: "flex", alignItems: "center", gap: 10, padding: "8px 10px",
borderRadius: 4, cursor: "pointer",
background: isActive ? "rgba(115,103,240,.08)" : "transparent",
transition: "background .1s",
}}
onMouseEnter={e => !isActive && (e.currentTarget.style.background = "rgba(47,43,61,.04)")}
onMouseLeave={e => !isActive && (e.currentTarget.style.background = "transparent")}
>
{ch.title}
{ch.username &&
@{ch.username}
}
{isActive &&
}
);
})}
)}
);
}
function StatusChip({ platform, label, status = "ok", href }) {
// Multi-tenant statuses (spec 001):
// ok — провайдер подключён (зелёная точка)
// warn — внимание, частично работает (оранжевая)
// err — ошибка (красная)
// off — не подключён (нейтральный, dashed border, идёт по href)
const palette = {
ok: { dot: "#28C76F", bg: "rgba(40,199,111,.16)" },
warn: { dot: "#FF9F43", bg: "rgba(255,159,67,.16)" },
err: { dot: "#FF4C51", bg: "rgba(255,76,81,.16)" },
off: { dot: "#A8A8B3", bg: "rgba(168,168,179,.20)" },
};
const { dot, bg } = palette[status] || palette.ok;
const isOff = status === "off";
const baseStyle = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px 4px 4px",
background: "#fff",
border: isOff ? "1px dashed var(--divider)" : "1px solid var(--divider)",
borderRadius: 999,
fontFamily: "var(--font-sans)",
color: isOff ? "var(--text-secondary)" : "var(--text-primary)",
textDecoration: "none",
cursor: href ? "pointer" : "default",
transition: "border-color .12s, background .12s",
};
const labelStyle = {
fontSize: 12,
color: isOff ? "var(--text-secondary)" : "var(--text-primary)",
fontFamily: isOff ? "var(--font-sans)" : "var(--font-mono)",
fontWeight: isOff ? 500 : 400,
};
const dotStyle = {
width: 6, height: 6, borderRadius: "50%",
background: dot,
boxShadow: `0 0 0 2px ${bg}`,
};
const inner = (
<>
{label}
>
);
if (href) {
return (
{ e.currentTarget.style.borderColor = isOff ? "rgba(115,103,240,.5)" : "var(--divider)"; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = "var(--divider)"; }}>
{inner}
);
}
return
{inner}
;
}
// Reusable modal with Vuexy-matched radius (6px from --border-radius-md)
function Modal({ title, subtitle, onClose, children, width = 520, footer }) {
// Клик по фону НЕ закрывает модалку — только крестик и кнопки в футере.
// Это спасает от случайной потери введённых данных.
// Escape закрывает модалку (все окна на базе Modal).
React.useEffect(() => {
const onKey = (e) => { if (e.key === "Escape" && onClose) onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
e.stopPropagation()} style={{
width: "100%", maxWidth: width, maxHeight: "calc(100vh - 40px)",
background: "#fff", borderRadius: 6,
boxShadow: "0 24px 48px -12px rgba(47,43,61,.32), 0 0 0 1px rgba(47,43,61,.06)",
display: "flex", flexDirection: "column", overflow: "hidden",
animation: "modalIn .22s cubic-bezier(0.4,0,0.2,1)",
}}>
{title}
{subtitle &&
{subtitle}
}
{children}
{footer && (
{footer}
)}
);
}
function Card({ radius = 6, pad = 0, style, children, ...rest }) {
// Figma: Card node 6640:52133, Light/elevation/gray/shadow-md
// border-radius: var(--border-radius-md, 6px)
// box-shadow: 0px 3px 12px 0px rgba(47, 43, 61, 0.06)
return (
{children}
);
}
// ==================== CALENDAR ====================
function EventChip({ ev, onClick }) {
const variants = {
pending: { bg: "rgba(115,103,240,.12)", bgHover: "rgba(115,103,240,.20)", fg: "#5A52C4", dot: "#7367F0" },
published: { bg: "rgba(47,43,61,.06)", bgHover: "rgba(47,43,61,.12)", fg: "var(--text-secondary)", dot: "#A5A3AE" },
error: { bg: "rgba(255,76,81,.12)", bgHover: "rgba(255,76,81,.20)", fg: "#BF393D", dot: "#FF4C51" },
};
const v = variants[ev.status] || variants.pending;
const [hover, setHover] = React.useState(false);
// Если задан кастомный цвет — hover делаем чуть насыщеннее (2A vs 1F).
const bgDefault = ev.color ? `${ev.color}1F` : v.bg;
const bgHovered = ev.color ? `${ev.color}33` : v.bgHover;
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "3px 8px", borderRadius: 4,
background: hover ? bgHovered : bgDefault,
color: ev.color || v.fg, fontSize: 11.5, fontWeight: 500,
lineHeight: 1.35, cursor: "pointer", overflow: "hidden",
textDecoration: ev.status === "published" ? "line-through" : "none",
opacity: ev.status === "published" ? (hover ? 0.95 : 0.7) : 1,
transition: "background .12s, opacity .12s",
}}>
{ev.time}
{ev.channel}
);
}
function MonthGrid({ year, month, today, events, radius = 10, onDayClick, onEventClick, onShowMore }) {
// month: 0-11
const first = new Date(year, month, 1);
const firstWeekday = (first.getDay() + 6) % 7; // Monday=0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const prevMonthDays = new Date(year, month, 0).getDate();
const cells = [];
// Preceding month tail
for (let i = firstWeekday - 1; i >= 0; i--) {
cells.push({ d: prevMonthDays - i, out: true, key: `p${i}`, mo: month - 1 });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ d, out: false, key: `c${d}`, mo: month });
}
while (cells.length % 7 !== 0 || cells.length < 42) {
const n = cells.length - (firstWeekday + daysInMonth) + 1;
cells.push({ d: n, out: true, key: `n${n}`, mo: month + 1 });
if (cells.length >= 42) break;
}
const eventsByDay = {};
for (const ev of events) {
if (ev.month === month && ev.year === year) {
(eventsByDay[ev.day] = eventsByDay[ev.day] || []).push(ev);
}
}
const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
return (
{weekdays.map((w, i) => (
= 5 ? "left" : "left",
}}>{w}
))}
{cells.map((cell, i) => {
const isToday = !cell.out && cell.d === today.day && month === today.month && year === today.year;
const isWeekend = i % 7 >= 5;
const evs = cell.out ? [] : (eventsByDay[cell.d] || []);
const maxShown = 3;
const shown = evs.slice(0, maxShown);
const overflow = evs.length - shown.length;
return (
!cell.out && onDayClick && onDayClick(cell.d)} style={{
minHeight: 118, padding: "8px 8px 10px",
minWidth: 0, overflow: "hidden",
borderRight: "1px solid var(--divider)",
borderBottom: "1px solid var(--divider)",
background: cell.out ? "rgba(47,43,61,0.015)"
: isWeekend ? "rgba(47,43,61,0.02)" : "#fff",
cursor: cell.out ? "default" : "pointer",
transition: "background .12s",
position: "relative",
}}
onMouseEnter={e => !cell.out && (e.currentTarget.style.background = "rgba(115,103,240,0.04)")}
onMouseLeave={e => {
if (cell.out) return;
e.currentTarget.style.background = isWeekend ? "rgba(47,43,61,0.02)" : "#fff";
}}
>
{cell.d}
{shown.map((ev, j) => (
{ e.stopPropagation(); onEventClick && onEventClick(ev); }} />
))}
{overflow > 0 && (
{ e.stopPropagation(); onShowMore && onShowMore(cell.d); }}
onMouseEnter={e => (e.currentTarget.style.color = "#5A52C4")}
onMouseLeave={e => (e.currentTarget.style.color = "var(--text-secondary)")}
style={{
fontSize: 11, color: "var(--text-secondary)", fontWeight: 500,
padding: "2px 8px", cursor: "pointer",
transition: "color .12s",
}}>ещё {overflow}
)}
);
})}
);
}
function UpcomingPostRow({ ev, onClick }) {
const [hover, setHover] = React.useState(false);
return (
setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: "flex", alignItems: "center", gap: 12,
padding: "12px 14px", borderRadius: 8, cursor: "pointer",
background: hover ? "rgba(47,43,61,0.03)" : "transparent",
transition: "background .12s",
}}>
);
}
// === Multi-tenant Provider chips (spec 001-multi-tenant-auth) ===
// Shared компонент: пара status-chips (TG + MAX) в header странички, driven от window.USER.
// Использование:
— всё остальное берётся из window.USER.connections.
// Пример override в Settings.html / Onboarding.html: window.USER = {...window.USER, connections: {tg:{connected:false,...}}}
function ProviderStatusChips({ gap = 8 }) {
// spec 004 — fetch /api/me. Preview-режим (без INITIAL_DATA) — fallback на window.USER mock.
const [user, setUser] = React.useState(null);
React.useEffect(() => {
const apiKey = (typeof localStorage !== "undefined") ? (localStorage.getItem("api_key") || "") : "";
if (!apiKey || !window.INITIAL_DATA) return;
fetch("/api/me", { headers: { "X-API-Key": apiKey } })
.then(r => r.ok ? r.json() : null)
.then(setUser)
.catch(() => {});
}, []);
let tg = { connected: false, identity: "" };
let mx = { connected: false, identity: "" };
if (user) {
tg = {
connected: !!(user.tg_user_id || user.tg_bot_connected),
identity: user.tg_username ? `@${user.tg_username}` : (user.tg_first_name || user.tg_bot_username || ""),
};
mx = {
connected: !!user.max_connected,
identity: user.max_phone || "",
};
} else if (typeof window !== "undefined" && window.USER) {
tg = window.USER.connections?.tg || tg;
mx = window.USER.connections?.max || mx;
}
return (
);
}
// === Multi-tenant USER mock (spec 001-multi-tenant-auth) ===
// Shared mock state для всех страниц прототипа. На live-версии driven от
// /api/me + /api/max_me + tg_widget state.
// Чтобы продемонстрировать "не подключён" на конкретной странице — в начале
// её скрипта переопредели: window.USER = { ...window.USER, connections: {tg: {connected:false}, ...} }
const POSTILO_USER_DEFAULT = {
email: "artem@postilo.ru",
username: "artem_kovalev",
display_name: "Артём Ковалёв",
plan: "Pro",
is_admin: false, // admin = self-host owner; для multi-tenant production обычные юзеры is_admin=false
connections: {
tg: { connected: true, identity: "@artem_kovalev", since: "21 апр", tg_user_id: 482931, first_name: "Артём" },
max: { connected: true, identity: "+7 ···· 4821", since: "22 апр", phone_e164: "+79991234821", me_id: 152119897 },
},
};
if (!window.USER) window.USER = POSTILO_USER_DEFAULT;
// ── Vuexy form-dropdowns (spec 005) — заменяют нативные