/* 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 (
P
); } function PlatformGlyph({ platform, size = 20 }) { // window.STATIC_BASE = "/static/design-v2" injected by react_page_template; // falls back to "" for local prototype (python -m http.server). const base = (typeof window !== "undefined" && typeof window.STATIC_BASE === "string") ? window.STATIC_BASE : ""; // tg — официальная иконка с web.telegram.org/k if (platform === "tg") { return ( Telegram ); } // MAX — официальный фавикон с dev.max.ru return ( MAX ); } function ConnectionPill({ platform, account, status = "ok" }) { const statusColors = { ok: { bg: "rgba(40,199,111,.12)", fg: "#28C76F" }, warn: { bg: "rgba(255,159,67,.12)", fg: "#FF9F43" }, err: { bg: "rgba(255,76,81,.12)", fg: "#FF4C51" }, }; const s = statusColors[status]; return (
{platform === "tg" ? "Telegram бот" : "MAX аккаунт"} {account}
); } function SegmentedFilter({ value, onChange, options }) { return (
{options.map(o => { const active = o.value === value; return ( ); })}
); } function ChannelAvatar({ initials, color, platform, size = 48, radius = 10, avatar }) { return (
{avatar ? ( {initials} ) : (
{initials}
)}
); } function StatusDot({ status }) { const variants = { active: { dot: "#28C76F", label: "Отправлен", bg: "rgba(40,199,111,.12)", fg: "#1E9553" }, paused: { dot: "#FF9F43", label: "Пауза", bg: "rgba(255,159,67,.12)", fg: "#BF7732" }, error: { dot: "#FF4C51", label: "Ошибка", bg: "rgba(255,76,81,.12)", fg: "#BF393D" }, idle: { dot: "#808390", label: "Ожидание", bg: "rgba(128,131,144,.12)", fg: "#60626C" }, }; const v = variants[status]; return ( {v.label} ); } function ChannelSpark({ data, color = "#7367F0", width = 84, height = 28 }) { const w = width, h = height; const max = Math.max(...data, 1); const pts = data.map((d, i) => { const x = (i / (data.length - 1)) * w; const y = h - (d / max) * (h - 2) - 1; return `${x},${y}`; }); const area = `0,${h} ${pts.join(" ")} ${w},${h}`; const gid = `spark-${color.replace("#","")}`; return ( ); } function truncateWords(text, maxWords) { if (!text) return text; const words = text.split(/\s+/); if (words.length <= maxWords) return text; return words.slice(0, maxWords).join(" ") + "…"; } function formatCompact(n) { if (n == null) return ""; if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M"; if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"; return String(n); } function renderGrowth(value, sparkData) { if (value == null) return null; const positive = value > 0; const negative = value < 0; const color = positive ? "#28C76F" : negative ? "#FF4C51" : "var(--text-secondary)"; return ( {positive ? "+" : ""}{value} {sparkData && } ); } function SortIndicator({ active, dir }) { const color = active ? "#7367F0" : "var(--text-secondary)"; const opacity = active ? 1 : 0.5; return ( {active ? (dir === "asc" ? "↑" : "↓") : "↕"} ); } function MobileChannelCard({ ch, onMappingClick, onAddMapping, isLast }) { const handleNav = () => { window.location.href = `/channel/${ch.id}`; }; const mappings = ch.mappings || (ch.pairedWith ? [ch.pairedWith] : []); const displayTitle = truncateWords(ch.title, 5); const subText = ch.platform === "max" ? (ch.chatId || (ch.id ? String(ch.id) : "")) : (ch.username ? `@${ch.username}` : ""); const Stat = ({ label, value, sparkData, color }) => (
{label} {value} {sparkData && }
); const fmtGrowth = (v) => v == null ? "—" : (v > 0 ? `+${v}` : String(v)); const colorGrowth = (v) => v > 0 ? "#28C76F" : v < 0 ? "#FF4C51" : "var(--text-secondary)"; return (
{/* Header: avatar + title + dots */}
{displayTitle}
{subText}
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("Удаление (демо)"); }}>Удалить канал
{/* Mappings row */}
e.stopPropagation()} style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}> Кросспостинг: {mappings.length > 0 ? mappings.map(m => ( )) : }
{/* Subs + views — 2 cols */}
=1000) return (n/1000).toFixed(1).replace(/\.0$/,"")+"k"; return n; })(ch.views24h||0)} />
{/* Growth — 3 cols в один ряд */}
); } function AddMappingButton({ ch, onAddMapping }) { const [hover, setHover] = React.useState(false); return ( ); } function ChannelRow({ ch, density, radius, striped, onClick, isLast, onMappingClick, onAddMapping }) { const vpad = density === "compact" ? 10 : density === "spacious" ? 18 : 14; const [hover, setHover] = React.useState(false); const [pillHoverId, setPillHoverId] = React.useState(null); const bg = hover ? "rgba(47,43,61,0.03)" : (striped ? "rgba(47,43,61,0.02)" : "transparent"); const cell = { padding: `${vpad}px 20px`, borderBottom: isLast ? "none" : "1px solid var(--divider)", verticalAlign: "middle" }; const handleClick = onClick || (() => { window.location.href = `/channel/${ch.id}`; }); const mappings = ch.mappings || (ch.pairedWith ? [ch.pairedWith] : []); const displayTitle = truncateWords(ch.title, 5); const MAPPING_VISIBLE = 1; const [mappingsExpanded, setMappingsExpanded] = React.useState(false); const [moreHover, setMoreHover] = React.useState(false); const visibleMappings = mappingsExpanded ? mappings : mappings.slice(0, MAPPING_VISIBLE); const hiddenCount = Math.max(0, mappings.length - MAPPING_VISIBLE); 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 (
{open && (
Уведомления
{unread > 0 ? `${unread} новых` : "Всё прочитано"}
{unread > 0 && ( )}
)}
); } function PostiloSidebar({ active, onNavigate, counts = {}, accent = "#7367F0", user = { name: "Артём Ковалёв", role: "Владелец · Pro", initials: "АК" } }) { const [mobileOpen, setMobileOpen] = React.useState(false); // spec 004 — counts из backend (TG + MAX каналы суммарно). // Preview-режим (без INITIAL_DATA) — fallback на mock counts из props. const [liveCounts, setLiveCounts] = React.useState(null); const [liveUser, setLiveUser] = React.useState(null); React.useEffect(() => { const apiKey = (typeof localStorage !== "undefined") ? (localStorage.getItem("api_key") || "") : ""; if (!apiKey || !window.INITIAL_DATA) return; const headers = { "X-API-Key": apiKey }; Promise.all([ fetch("/api/tg_channels?fast=1", { headers }).then(r => r.ok ? r.json() : []).catch(() => []), fetch("/api/max_channels?fast=1", { headers }).then(r => r.ok ? r.json() : []).catch(() => []), ]).then(([tg, mx]) => { setLiveCounts({ channels: (Array.isArray(tg) ? tg.length : 0) + (Array.isArray(mx) ? mx.length : 0), }); }).catch(() => {}); fetch("/api/me", { headers }).then(r => r.ok ? r.json() : null) .then(d => { if (!d) return; const initials = (d.tg_first_name || d.username || d.email || "?").trim(); const ini = initials.split(/\s+/).map(w => w[0] || "").join("").slice(0, 2).toUpperCase() || "?"; setLiveUser({ name: d.tg_first_name || d.username || d.email || "", role: d.is_admin ? "Владелец · " + (d.plan_tier || "free").toUpperCase() : (d.plan_tier || "free").toUpperCase(), initials: ini, }); }).catch(() => {}); }, []); const c = { channels: counts.channels, calendar: counts.calendar, ...(liveCounts || {}) }; const u = liveUser || user; // Main workspace navigation — задачи, с которыми пользователь работает каждый день const items = [ { id: "channels", icon: "hash", label: "Каналы", count: c.channels, href: "/channels" }, { id: "calendar", icon: "calendar-event", label: "Календарь", count: c.calendar, href: "/calendar" }, ]; return ( <> {/* Mobile topbar (only <900px) */}
Postilo
{/* Backdrop when drawer open */} {mobileOpen && (
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 && ( {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", }}>
{ev.monthShort}
{ev.day}
{ev.channel}
{ev.preview}
{ev.time}
{ev.relative}
); } // === 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) — заменяют нативные , // т.к. браузерные поповеры не стилизуются. Позиционирование fixed (анти-clip в модалках). function useDropdownCoords(open, ref, dropH = 280) { const [coords, setCoords] = React.useState({ top: 0, left: 0, width: 0, flipUp: false }); React.useEffect(() => { if (!open || !ref.current) return; const compute = () => { const r = ref.current.getBoundingClientRect(); 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]); return coords; } function VuexySelect({ value, onChange, options, width = "100%", searchable = false, placeholder = "Выбрать", icon = null }) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const ref = React.useRef(); const searchRef = React.useRef(); const coords = useDropdownCoords(open, ref); const selected = options.find(o => String(o.value) === String(value)); React.useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); if (searchable && searchRef.current) setTimeout(() => searchRef.current && searchRef.current.focus(), 30); return () => document.removeEventListener("mousedown", handler); }, [open, searchable]); React.useEffect(() => { if (!open) setQuery(""); }, [open]); const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return options; return options.filter(o => String(o.label).toLowerCase().includes(q)); }, [query, options]); return (
setOpen(!open)} style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 12px", background: "#fff", border: `1px solid ${open ? "#7367F0" : "var(--input-border)"}`, borderRadius: 6, cursor: "pointer", minHeight: 40, boxSizing: "border-box", boxShadow: open ? "0 0 0 3px rgba(115,103,240,.16)" : "none", transition: "border-color .12s, box-shadow .12s", }}> {icon && } {selected ? selected.label : placeholder}
{open && (
{searchable && options.length > 6 && (
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(o => { const isActive = String(o.value) === String(value); return (
{ onChange(o.value); 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")}> {o.label} {isActive && }
); })}
)}
); } function VuexyDatePicker({ value, onChange, width = "100%" }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(); const coords = useDropdownCoords(open, ref, 320); const pad = n => String(n).padStart(2, "0"); const parsed = React.useMemo(() => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value || ""); if (m) return { y: +m[1], mo: +m[2] - 1, d: +m[3] }; const t = new Date(); return { y: t.getFullYear(), mo: t.getMonth(), d: t.getDate() }; }, [value]); const [viewY, setViewY] = React.useState(parsed.y); const [viewM, setViewM] = React.useState(parsed.mo); React.useEffect(() => { if (open) { setViewY(parsed.y); setViewM(parsed.mo); } }, [open]); React.useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const MONTHS = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"]; const WD = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const firstDow = (new Date(viewY, viewM, 1).getDay() + 6) % 7; const daysInMonth = new Date(viewY, viewM + 1, 0).getDate(); const cells = []; for (let i = 0; i < firstDow; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); const today = new Date(); const isToday = d => d && viewY === today.getFullYear() && viewM === today.getMonth() && d === today.getDate(); const isSel = d => d && value && viewY === parsed.y && viewM === parsed.mo && d === parsed.d; const navBtn = { width: 28, height: 28, border: "none", background: "transparent", borderRadius: 6, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", color: "var(--text-secondary)" }; const fmtDisplay = value ? `${pad(parsed.d)}.${pad(parsed.mo + 1)}.${parsed.y}` : "Выбрать дату"; return (
setOpen(!open)} style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 12px", background: "#fff", border: `1px solid ${open ? "#7367F0" : "var(--input-border)"}`, borderRadius: 6, cursor: "pointer", minHeight: 40, boxSizing: "border-box", boxShadow: open ? "0 0 0 3px rgba(115,103,240,.16)" : "none", transition: "border-color .12s, box-shadow .12s", }}> {fmtDisplay}
{open && (
{MONTHS[viewM]} {viewY}
{WD.map(w =>
{w}
)}
{cells.map((d, i) => d === null ?
: (
{ onChange(`${viewY}-${pad(viewM + 1)}-${pad(d)}`); setOpen(false); }} style={{ height: 32, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, borderRadius: 6, cursor: "pointer", background: isSel(d) ? "#7367F0" : "transparent", color: isSel(d) ? "#fff" : isToday(d) ? "#7367F0" : "var(--text-primary)", fontWeight: (isSel(d) || isToday(d)) ? 600 : 400, border: isToday(d) && !isSel(d) ? "1px solid rgba(115,103,240,.4)" : "1px solid transparent", transition: "background .1s", }} onMouseEnter={e => { if (!isSel(d)) e.currentTarget.style.background = "rgba(115,103,240,.10)"; }} onMouseLeave={e => { if (!isSel(d)) e.currentTarget.style.background = "transparent"; }}> {d}
))}
)}
); } function VuexyTimePicker({ value, onChange, width = "100%" }) { const [open, setOpen] = React.useState(false); const [inputVal, setInputVal] = React.useState(value || ""); const ref = React.useRef(); const inputRef = React.useRef(); const coords = useDropdownCoords(open, ref); // Sync when parent changes value React.useEffect(() => { setInputVal(value || ""); }, [value]); // Close on outside click React.useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); // 30-min presets for quick pick const presets = React.useMemo(() => { const out = []; for (let h = 0; h < 24; h++) for (const m of [0]) { out.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`); } return out; }, []); const isValid = (s) => /^([01]\d|2[0-3]):([0-5]\d)$/.test(s); const handleInputChange = (e) => { let v = e.target.value; // auto-insert colon after 2 digits if (/^\d{2}$/.test(v) && (inputVal.length < 2)) v = v + ":"; setInputVal(v); if (isValid(v)) onChange(v); }; const handleKeyDown = (e) => { if (e.key === "Enter") { if (isValid(inputVal)) { onChange(inputVal); setOpen(false); } } if (e.key === "Escape") { setInputVal(value || ""); setOpen(false); } }; const handleBlur = () => { // restore last valid value if input is invalid if (!isValid(inputVal)) setInputVal(value || ""); }; const handleSelect = (t) => { setInputVal(t); onChange(t); setOpen(false); }; return (
{ setOpen(true); setTimeout(() => inputRef.current?.focus(), 20); }}> setOpen(true)} onKeyDown={handleKeyDown} onBlur={handleBlur} placeholder="ЧЧ:ММ" maxLength={5} style={{ flex: 1, border: "none", outline: "none", fontSize: 14, color: "var(--text-primary)", fontFamily: "var(--font-sans)", background: "transparent", padding: "10px 0", cursor: "text", }} onClick={e => e.stopPropagation()} /> { e.stopPropagation(); setOpen(v => !v); }} />
{open && (
{presets.map(t => { const isActive = t === value; return (
handleSelect(t)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 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")}> {t} {isActive && }
); })}
)}
); } // Русский плюрал: 1 час / 2 часа / 5 часов. function pluralRu(n, one, few, many) { const m10 = Math.abs(n) % 10, m100 = Math.abs(n) % 100; if (m100 >= 11 && m100 <= 19) return many; if (m10 === 1) return one; if (m10 >= 2 && m10 <= 4) return few; return many; } // Человеческое описание интервала авто-удаления в секундах. function humanAutoDelete(s) { s = Number(s) || 0; if (s <= 0) return ""; const presets = { 3600: "через 1 час", 7200: "через 2 часа", 43200: "через 12 часов", 86400: "через 1 день", 172800: "через 2 дня", 604800: "через неделю", 2592000: "через месяц", }; if (presets[s]) return presets[s]; let days = Math.floor(s / 86400); let hours = Math.round((s - days * 86400) / 3600); if (hours >= 24) { days += Math.floor(hours / 24); hours = hours % 24; } if (days > 0 && hours > 0) { return `через ${days} ${pluralRu(days, "день", "дня", "дней")} ${hours} ${pluralRu(hours, "час", "часа", "часов")}`; } if (days > 0) return `через ${days} ${pluralRu(days, "день", "дня", "дней")}`; const h = Math.max(1, Math.round(s / 3600)); return `через ${h} ${pluralRu(h, "час", "часа", "часов")}`; } // Селектор «Авто-удаление» с пресетами + опция «Своё значение…» // (разворачивает inline-редактор «N [часы/дни]»). value=секунды; 0 = выключено. function AutoDeleteField({ value, onChange, maxSeconds }) { const lim = Number(maxSeconds) || 0; // 0 = без лимита const ALL_PRESETS = [ { v: 0, label: "Не удалять" }, { v: 3600, label: "Через 1 час" }, { v: 7200, label: "Через 2 часа" }, { v: 43200, label: "Через 12 часов" }, { v: 86400, label: "Через 1 день" }, { v: 172800, label: "Через 2 дня" }, { v: 604800, label: "Через неделю" }, { v: 2592000, label: "Через месяц" }, ]; const PRESETS = React.useMemo(() => { if (!lim) return ALL_PRESETS; const filtered = ALL_PRESETS.filter(p => p.v === 0 || p.v <= lim); // Добавляем сам лимит как опцию «максимум», если он не совпал с пресетом. if (!filtered.some(p => p.v === lim)) { const lbl = humanAutoDelete(lim).replace(/^через /, "Через "); filtered.push({ v: lim, label: lbl }); } return filtered; }, [lim]); const presetVals = React.useMemo(() => new Set(PRESETS.map(p => p.v)), [PRESETS]); const isCustom = value > 0 && !presetVals.has(Number(value)); const initDays = isCustom ? Math.floor(value / 86400) : 0; const initHours = isCustom ? Math.round((value % 86400) / 3600) : 1; const [showCustom, setShowCustom] = React.useState(isCustom); const [daysStr, setDaysStr] = React.useState(String(initDays)); const [hoursStr, setHoursStr] = React.useState(String(initHours)); const dropdownOptions = [ ...PRESETS.map(p => ({ value: p.v, label: p.label })), { value: "custom", label: "Своё значение…" }, ]; const selectValue = isCustom || showCustom ? "custom" : Number(value); // Лимиты: дни ограничены lim; часы 0–23 (сверх — уже сутки). const customMaxDays = lim ? Math.floor(lim / 86400) : 9999; const customMaxHours = (lim && customMaxDays === 0) ? Math.floor(lim / 3600) : 23; const propagate = (d, h) => { let secs = d * 86400 + h * 3600; if (lim && secs > lim) secs = lim; onChange(secs); }; const onDaysChange = (s) => { setDaysStr(s); const d = parseInt(s, 10) || 0, h = parseInt(hoursStr, 10) || 0; if (d * 86400 + h * 3600 >= 3600) propagate(d, h); }; const onHoursChange = (s) => { setHoursStr(s); const d = parseInt(daysStr, 10) || 0, h = parseInt(s, 10) || 0; if (d * 86400 + h * 3600 >= 3600) propagate(d, h); }; // Нормализация на blur: пусто/0 → минимум 1 час; кламп по лимитам. const normalize = () => { let d = parseInt(daysStr, 10) || 0, h = parseInt(hoursStr, 10) || 0; if (d > customMaxDays) d = customMaxDays; if (h > customMaxHours) h = customMaxHours; if (d * 86400 + h * 3600 < 3600) { d = 0; h = 1; } setDaysStr(String(d)); setHoursStr(String(h)); propagate(d, h); }; const numInput = { width: 64, padding: "9px 10px", fontSize: 14, border: "1px solid var(--input-border)", borderRadius: 6, background: "#fff", outline: "none", fontFamily: "var(--font-sans)", color: "var(--text-primary)", fontVariantNumeric: "tabular-nums", boxSizing: "border-box", }; const unitLbl = { fontSize: 13, color: "var(--text-secondary)" }; return (
{ if (v === "custom") { setShowCustom(true); const d = parseInt(daysStr, 10) || 0, h = parseInt(hoursStr, 10) || 1; propagate(d, h); } else { setShowCustom(false); onChange(Number(v)); } }} options={dropdownOptions} /> {showCustom && (
Через onDaysChange(e.target.value)} onBlur={normalize} style={numInput} /> дн onHoursChange(e.target.value)} onBlur={normalize} style={numInput} /> ч
)}
); } function VuexyCheckbox({ checked, onChange, label, size = 16, disabled = false }) { const toggle = (e) => { if (e) { e.preventDefault(); e.stopPropagation(); } if (!disabled && onChange) onChange(!checked); }; const box = ( {checked && ( )} ); if (label === undefined || label === null) { return {box}; } return ( {box} {label} ); } // === TelegramEditor — contenteditable с постоянной панелью форматирования сверху === // value: Telegram HTML строка; onChange(tgHtml) при каждом изменении. function TelegramEditor({ value, onChange, placeholder, maxLength = 4096 }) { const edRef = React.useRef(null); const savedSel = React.useRef(null); const [lnkDlg, setLnkDlg] = React.useState(false); const [lnkVal, setLnkVal] = React.useState(""); const [cnt, setCnt] = React.useState(0); React.useEffect(() => { const el = edRef.current; if (el) { el.innerHTML = value || ""; setCnt((el.innerText || "").replace(/\n$/, "").length); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const toTg = (html) => { const tmp = document.createElement("div"); tmp.innerHTML = html; const esc = s => s.replace(/&/g,"&").replace(//g,">"); const atr = s => String(s||"").replace(/"/g,"""); function wk(n) { if (n.nodeType===3) return esc(n.textContent); if (n.nodeType!==1) return ""; const t=n.tagName.toLowerCase(), k=Array.from(n.childNodes).map(wk).join(""); if(t==="b"||t==="strong") return `${k}`; if(t==="i"||t==="em") return `${k}`; if(t==="u") return `${k}`; if(t==="s"||t==="strike"||t==="del") return `${k}`; if(t==="code") return `${k}`; if(t==="pre") return `
${k}
`; if(t==="tg-spoiler") return `${k}`; if(t==="blockquote") return `
${k}
`; if(t==="a") return `${k}`; if(t==="br") return "\n"; if(t==="div"||t==="p") return k ? k+"\n" : "\n"; return k; } return Array.from(tmp.childNodes).map(wk).join("").replace(/\n+$/,""); }; const emit = () => { const el = edRef.current; if (!el) return; setCnt((el.innerText||"").replace(/\n$/,"").length); if (onChange) onChange(toTg(el.innerHTML)); }; const savSel = () => { const s=window.getSelection(); if(s&&!s.isCollapsed) savedSel.current=s.getRangeAt(0).cloneRange(); }; const rstSel = () => { const s=window.getSelection(); if(savedSel.current&&s){s.removeAllRanges();s.addRange(savedSel.current);} }; const cmd = (c,v) => { edRef.current?.focus(); document.execCommand(c,false,v||null); emit(); }; const wrap = (tag) => { const sel=window.getSelection(); if(!sel||sel.isCollapsed) return; const range=sel.getRangeAt(0); try { const frag=range.extractContents(); const el=document.createElement(tag); el.appendChild(frag); range.insertNode(el); } catch(e) {} emit(); }; const openLnk = () => { savSel(); setLnkVal(""); setLnkDlg(true); }; const insLnk = () => { if (!lnkVal.trim()) { setLnkDlg(false); return; } rstSel(); edRef.current?.focus(); document.execCommand("createLink", false, lnkVal.trim()); setLnkDlg(false); emit(); }; const onKd = (e) => { const C = e.ctrlKey||e.metaKey; if (!C) return; if (e.key==="b") { e.preventDefault(); cmd("bold"); } else if (e.key==="i") { e.preventDefault(); cmd("italic"); } else if (e.key==="u") { e.preventDefault(); cmd("underline"); } else if (e.key==="k") { e.preventDefault(); openLnk(); } else if (e.shiftKey && e.key==="X") { e.preventDefault(); cmd("strikeThrough"); } else if (e.shiftKey && e.key==="M") { e.preventDefault(); wrap("code"); } else if (e.shiftKey && e.key==="P") { e.preventDefault(); wrap("tg-spoiler"); } else if (e.shiftKey && e.key===".") { e.preventDefault(); wrap("blockquote"); } else if (e.shiftKey && e.key==="N") { e.preventDefault(); cmd("removeFormat"); } }; const BTNS = [ { l:"B", t:"Жирный (Ctrl+B)", s:{fontWeight:700}, fn:()=>cmd("bold") }, { l:"I", t:"Курсив (Ctrl+I)", s:{fontStyle:"italic"}, fn:()=>cmd("italic") }, { l:"U", t:"Подчёркнутый (Ctrl+U)", s:{textDecoration:"underline"}, fn:()=>cmd("underline") }, { l:"S", t:"Зачёркнутый (Ctrl+Shift+X)", s:{textDecoration:"line-through"}, fn:()=>cmd("strikeThrough") }, null, { l:"", t:"Моноширинный (Ctrl+Shift+M)", s:{fontFamily:"monospace",fontSize:10}, fn:()=>wrap("code") }, { l:"‖‖", t:"Спойлер (Ctrl+Shift+P)", s:{fontFamily:"monospace",fontSize:10}, fn:()=>wrap("tg-spoiler") }, { l:"❝", t:"Цитата (Ctrl+Shift+.)", s:{fontSize:14}, fn:()=>wrap("blockquote") }, { l:"🔗", t:"Ссылка (Ctrl+K)", s:{}, fn:openLnk }, null, { l:"✕", t:"Убрать форматирование (Ctrl+Shift+N)", s:{opacity:.55}, fn:()=>cmd("removeFormat") }, ]; const tbBtn = { background:"transparent", border:"none", cursor:"pointer", color:"var(--text-secondary)", padding:"2px 7px", borderRadius:4, fontSize:12, minWidth:26, height:26, display:"inline-flex", alignItems:"center", justifyContent:"center", lineHeight:1, }; return (
{/* Постоянная панель сверху */}
{BTNS.map((b,i) => b===null ?
: )}
{/* Поле ввода */}
{cnt===0 && (
{placeholder||"Текст публикации…"}
)}
{/* Диалог вставки ссылки */} {lnkDlg && (
setLnkVal(e.target.value)} placeholder="https://..." onKeyDown={e=>{if(e.key==="Enter")insLnk();if(e.key==="Escape")setLnkDlg(false);}} style={{flex:1,border:"1px solid var(--divider)",borderRadius:6, padding:"5px 10px",fontSize:13,outline:"none"}} />
)}
{cnt} / {maxLength}
); } Object.assign(window, { TIP, PostiloMark, PlatformGlyph, ConnectionPill, SegmentedFilter, ChannelAvatar, StatusDot, ChannelSpark, ChannelRow, PostiloSidebar, SidebarLink, PageHeader, Card, StatsInline, StatusChip, ProviderStatusChips, Modal, Dropdown, DropdownItem, DropdownDivider, ChannelPicker, EventChip, MonthGrid, UpcomingPostRow, VuexySelect, VuexyDatePicker, VuexyTimePicker, VuexyCheckbox, AutoDeleteField, humanAutoDelete, pluralRu, TelegramEditor, POSTILO_USER_DEFAULT, });