/* global React, CR_UI, CR_API */ const { useState } = React; const { Icons, Btn, EmptyState } = CR_UI; // Render TG-style markdown (bold, italic, leave newlines) function renderTgText(s) { s = s.replace(/&/g, "&").replace(//g, ">"); s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); s = s.replace(/(?$1"); return s; } const PostsScreen = ({ onCopy, onPostsChanged, brand, navTarget, onNavConsumed }) => { const [tab, setTab] = useState("drafts"); const [drafts, setDrafts] = useState(null); // null=loading, []=empty const [published, setPublished] = useState(null); const [loadError, setLoadError] = useState(null); const [activeId, setActiveId] = useState(null); // post body cache: topicId → { text, loaded } const [bodyCache, setBodyCache] = useState({}); const [bodyLoading, setBodyLoading] = useState(false); // poll while there's an active generate_post job for this brand so a new // draft appears without manual reload const [polling, setPolling] = useState(false); // councilRunning persists across modal open/close — guards against duplicate re-runs const [councilRunning, setCouncilRunning] = useState(false); const [councilModal, setCouncilModal] = useState(null); // null | { topic_id, result } const [councilCache, setCouncilCache] = useState({}); // Post draft versioning const [draftVersions, setDraftVersions] = useState({ history: [], current: 1 }); const [selectedDraftVersion, setSelectedDraftVersion] = useState(null); // null = current const [historyBodyCache, setHistoryBodyCache] = useState({}); // `${topicId}_v${n}` → body const [refinePostRunning, setRefinePostRunning] = useState(false); const [bodyRefreshToken, setBodyRefreshToken] = useState(0); const activeIdRef = React.useRef(activeId); React.useEffect(() => { activeIdRef.current = activeId; }, [activeId]); // Poll for council result while viewing a draft that has no result yet. // Council runs in background after generate_post — this picks it up. React.useEffect(() => { if (!brand || !activeId || tab !== "drafts") return; if (councilCache[activeId]) return; let cancelled = false; let attempts = 0; const poll = async () => { if (cancelled || attempts >= 24) return; // max ~2 min attempts++; try { const result = await CR_API.getCouncil(brand, activeId); if (!cancelled && result) { setCouncilCache((c) => ({ ...c, [activeId]: result })); return; } } catch (_) {} if (!cancelled) setTimeout(poll, 5000); }; const tid = setTimeout(poll, 3000); return () => { cancelled = true; clearTimeout(tid); }; }, [brand, activeId, tab]); // eslint-disable-line react-hooks/exhaustive-deps // Keep onPostsChanged in a ref so fetchPosts doesn't change identity each // render — without this, the brand-change effect would call setDrafts(null) // on every render, leaving the list stuck on "Загрузка…" forever. const onPostsChangedRef = React.useRef(onPostsChanged); React.useEffect(() => { onPostsChangedRef.current = onPostsChanged; }, [onPostsChanged]); const fetchPosts = React.useCallback(() => { if (!brand) return; setLoadError(null); Promise.all([ CR_API.listPostDrafts(brand).catch((err) => { throw err; }), CR_API.listPostsPublished(brand).catch((err) => { throw err; }), ]).then(([d, p]) => { setDrafts(d); setPublished(p); onPostsChangedRef.current && onPostsChangedRef.current(); }).catch((err) => setLoadError(err.message)); }, [brand]); // Fetch both lists when brand changes React.useEffect(() => { setActiveId(null); setDrafts(null); setPublished(null); fetchPosts(); }, [fetchPosts]); const list = tab === "drafts" ? (drafts || []) : (published || []); // Apply navTarget once posts are loaded React.useEffect(() => { if (!navTarget || navTarget.kind !== "post" || drafts === null || published === null) return; const wantTab = navTarget.folder === "published" ? "published" : "drafts"; setTab(wantTab); const targetList = wantTab === "drafts" ? drafts : published; if (targetList.find((p) => p.topic_id === navTarget.id)) { setActiveId(navTarget.id); } onNavConsumed && onNavConsumed(); }, [navTarget, drafts, published, onNavConsumed]); const postMeta = list.find((p) => p.topic_id === activeId) || list[0]; // Auto-select first post when drafts/published arrive or tab changes. // Without this, the body fetch effect (which keys on activeId) never fires // and the right pane stays on "Загрузка…" forever. React.useEffect(() => { if (navTarget) return; // wait for navTarget effect to drive selection if (drafts === null || published === null) return; // still loading if (activeId && list.find((p) => p.topic_id === activeId)) return; const first = list[0]; setActiveId(first?.topic_id || null); }, [tab, drafts, published, navTarget, list, activeId]); // Poll for new drafts while a generate_post job is running React.useEffect(() => { if (!brand || !polling) return; const t = setInterval(fetchPosts, 4000); return () => clearInterval(t); }, [brand, polling, fetchPosts]); React.useEffect(() => { if (!brand) return; let cancelled = false; async function check() { try { const jobs = await CR_API.listJobs(brand, true); if (cancelled) return; setPolling(jobs.some((j) => j.kind === "generate_post" && (j.status === "running" || j.status === "queued"))); } catch (_) {} } check(); const t = setInterval(check, 4000); return () => { cancelled = true; clearInterval(t); }; }, [brand]); // Fetch full post body lazily React.useEffect(() => { if (!brand || !activeId) return; const cacheKey = `${tab}:${activeId}`; if (bodyCache[cacheKey]) return; setBodyLoading(true); CR_API.getPost(brand, activeId, tab) .then((doc) => { setBodyCache((c) => ({ ...c, [cacheKey]: doc.body })); setBodyLoading(false); }) .catch(() => { setBodyCache((c) => ({ ...c, [cacheKey]: "" })); setBodyLoading(false); }); }, [brand, activeId, tab, bodyRefreshToken]); // eslint-disable-line react-hooks/exhaustive-deps // Load draft version list when switching topics or after a refine job finishes. React.useEffect(() => { if (!brand || !activeId || tab !== "drafts") return; setSelectedDraftVersion(null); CR_API.postDraftVersions(brand, activeId) .then(setDraftVersions) .catch(() => setDraftVersions({ history: [], current: 1 })); }, [brand, activeId, tab, bodyRefreshToken]); // eslint-disable-line react-hooks/exhaustive-deps // Fetch history body lazily when an old version is selected React.useEffect(() => { if (!brand || !activeId || !selectedDraftVersion) return; const key = `${activeId}_v${selectedDraftVersion}`; if (historyBodyCache[key] !== undefined) return; CR_API.getPostHistory(brand, activeId, selectedDraftVersion) .then((data) => setHistoryBodyCache((c) => ({ ...c, [key]: data.body }))) .catch(() => setHistoryBodyCache((c) => ({ ...c, [key]: "" }))); }, [brand, activeId, selectedDraftVersion]); // eslint-disable-line react-hooks/exhaustive-deps // Watch refine_post jobs; refresh post + versions when job finishes const wasRefinePostRunning = React.useRef(false); React.useEffect(() => { if (!brand) return; let cancelled = false; async function check() { try { const activeJobs = await CR_API.listJobs(brand, true); if (cancelled) return; const running = activeJobs.some( (j) => j.kind === "refine_post" && (j.status === "running" || j.status === "queued"), ); if (wasRefinePostRunning.current && !running) { const tid = activeIdRef.current; if (tid) { setBodyCache((c) => { const n = { ...c }; delete n[`drafts:${tid}`]; return n; }); setBodyRefreshToken((t) => t + 1); CR_API.postDraftVersions(brand, tid) .then((data) => { setDraftVersions(data); setSelectedDraftVersion(null); }) .catch(() => {}); } fetchPosts(); } wasRefinePostRunning.current = running; setRefinePostRunning(running); } catch (_) {} } check(); const t = setInterval(check, 4000); return () => { cancelled = true; clearInterval(t); }; }, [brand, fetchPosts]); // eslint-disable-line react-hooks/exhaustive-deps const cacheKey = `${tab}:${activeId}`; const historyKey = selectedDraftVersion ? `${activeId}_v${selectedDraftVersion}` : null; const postText = historyKey ? (historyBodyCache[historyKey] ?? null) : (bodyCache[cacheKey] ?? null); const loading = drafts === null || published === null; const isEmpty = !loading && list.length === 0; const renderStage = () => { if (loadError) { return (
Не удалось загрузить посты
{loadError}
); } if (loading) { return (
Загрузка…
); } if (isEmpty || !postMeta) { return ( } title={tab === "drafts" ? "Нет черновиков" : "Нет опубликованных постов"} body="Перейди в Articles, выбери одобренную тему и нажми «Сгенерировать пост»." /> ); } return (
{bodyLoading || postText === null ? (
) : (
{(brand || "").slice(0, 2).toUpperCase()} {brand}
{tab === "drafts" ? `черновик · ${new Date(postMeta.created_at).toLocaleDateString("ru-RU")}` : `опубликован · ${new Date(postMeta.created_at).toLocaleDateString("ru-RU")}`}
)} {!bodyLoading && postText !== null && (
Превью того, как пост будет выглядеть в Telegram Desktop
)} {tab === "drafts" && !bodyLoading && postText !== null && (() => { const cr = councilCache[postMeta?.topic_id]; if (!cr) return null; const decision = cr.verdict?.decision; const approved = decision === "approved"; const failed = decision === "failed"; return (
setCouncilModal({ topic_id: postMeta.topic_id, result: cr })} > {approved ? `✓ ${cr.verdict?.model || "Судья"} одобрил` : failed ? "⚠ Ошибка консилиума" : "✎ Нужны правки"} GPT {cr.reviews?.gpt?.score ?? "—"} · Gemini {cr.reviews?.gemini?.score ?? "—"} подробнее →
); })()}
); }; return (
Telegram · {brand}

Посты

Финальные посты для копирования в Telegram. Кнопка ⌘C копирует текст в буфер.
{postMeta && !isEmpty && (

{postMeta.topic_id}

{tab === "drafts" && draftVersions.history.length > 0 && ( )} } onClick={() => onCopy && onCopy({ text: postText })}>Скопировать в буфер {tab === "drafts" && ( } onClick={async () => { try { await CR_API.publishPost(brand, postMeta.topic_id); fetchPosts(); } catch (e) { alert("Ошибка публикации: " + e.message); } }}>Опубликовать )} } onClick={async () => { const folderLabel = tab === "drafts" ? "черновик" : "опубликованный пост"; if (!confirm(`Удалить ${folderLabel} «${postMeta.topic_id}»?\n\nФайл будет удалён из workspace, в git-истории остаётся коммит для отката.`)) return; try { await CR_API.deletePost(brand, postMeta.topic_id, tab); setActiveId(null); fetchPosts(); } catch (e) { alert("Ошибка удаления: " + e.message); } }}>Удалить
)}
{renderStage()}
{councilModal && (
setCouncilModal(null)}>
e.stopPropagation()}>
Консилиум · {councilModal.topic_id}
{councilRunning ? (
GPT и Gemini читают пост…
Параллельный анализ двух моделей, затем судья принимает решение. ~30–60 секунд.
) : councilModal.result ? (() => { const r = councilModal.result; const gpt = r.reviews?.gpt || {}; const gemini = r.reviews?.gemini || {}; const verdict = r.verdict || {}; const approved = verdict.decision === "approved"; const verdictFailed = verdict.decision === "failed"; return (
{approved ? `✓ ${verdict.model || "Судья"} одобрил пост` : verdictFailed ? `⚠ Ошибка консилиума` : `✎ ${verdict.model || "Судья"} рекомендует доработку`} {verdict.reasoning &&
{verdict.reasoning}
}
{verdict.final_suggestions?.length > 0 && (
Рекомендации {verdict.model || "судьи"}
    {verdict.final_suggestions.map((s, i) =>
  • {s}
  • )}
)} {[["GPT", gpt, gpt.model], ["Gemini", gemini, gemini.model]].map(([name, rv, model]) => (
{name} {model} {rv.score ?? "—"}/10
{rv.error &&
Ошибка: {rv.error}
} {rv.summary &&
{rv.summary}
} {rv.issues?.length > 0 && (
Проблемы:
    {rv.issues.map((s, i) =>
  • {s}
  • )}
)} {rv.suggestions?.length > 0 && (
Предложения:
    {rv.suggestions.map((s, i) =>
  • {s}
  • )}
)}
))} {r.errors?.length > 0 && (
{r.errors.join("; ")}
)}
Запущено: {new Date(r.created_at).toLocaleString("ru-RU")}
); })() : null}
setCouncilModal(null)}>Закрыть {!councilRunning && councilModal.result?.verdict?.decision !== "failed" && ( } disabled={refinePostRunning} onClick={async () => { const topicId = councilModal.topic_id; try { await CR_API.refinePost(brand, topicId); setCouncilModal(null); } catch (e) { alert("Ошибка: " + e.message); } }}> {refinePostRunning ? "Дорабатывается…" : "Доработать пост"} )} {!councilRunning && ( } onClick={async () => { const topicId = councilModal.topic_id; setCouncilRunning(true); try { const result = await CR_API.runCouncil(brand, topicId); setCouncilCache((c) => ({ ...c, [topicId]: result })); setCouncilModal((m) => m ? { ...m, result } : null); } catch (e) { alert("Ошибка: " + e.message); setCouncilModal(null); } finally { setCouncilRunning(false); } }}>Перезапустить )}
)}
); }; // ========================================================================= // Settings Screen // ========================================================================= const SETTINGS_TABS = [ { id: "brand", label: "Канал" }, { id: "settings", label: "Что искать" }, { id: "style", label: "Как писать" }, { id: "sources", label: "Где искать" }, ]; const HelperBlock = ({ items }) => (

Подсказки

    {items.map((i, k) =>
  • )}
); // --------------------------------------------------------------------------- // HistoryDrawer — shows bootstrap snapshots, supports rollback with confirm. // --------------------------------------------------------------------------- const HistoryDrawer = ({ open, onClose, onRestored, brand }) => { const [entries, setEntries] = React.useState(null); // null=loading, []=empty const [restoring, setRestoring] = React.useState(null); // snapshot_id being restored const [confirmId, setConfirmId] = React.useState(null); // snapshot_id awaiting confirm const reload = React.useCallback(() => { setEntries(null); CR_API.getBootstrapHistory(brand) .then(setEntries) .catch(() => setEntries([])); }, [brand]); React.useEffect(() => { if (open) reload(); }, [open, reload]); // ESC closes drawer React.useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); const handleRestore = async (id) => { setRestoring(id); try { const result = await CR_API.restoreBootstrapSnapshot(brand, id); onRestored && onRestored(id, result.pre_restore_id); reload(); } catch (e) { alert(e.status === 409 ? "Бренд занят активным запуском" : "Ошибка отката: " + e.message); } finally { setRestoring(null); setConfirmId(null); } }; if (!open) return null; return ( <>
); }; const SettingsScreen = ({ onSave, onBootstrap, brand }) => { const [tab, setTab] = useState("brand"); // Markdown buffers — start empty, fetch from API on mount. const [settingsBuf, setSettingsBuf] = React.useState(""); const [styleBuf, setStyleBuf] = React.useState(""); const [sourcesBuf, setSourcesBuf] = React.useState(""); // Brand metadata (brand.yaml) — controlled inputs. const [brandMeta, setBrandMeta] = React.useState({ slug: brand, channel: "", timezone: "Europe/Moscow", auto_discover_at: "09:00", website: "", theme_description: "", last_bootstrap_at: null, }); const [stats, setStats] = React.useState(null); const [bootstrapStats, setBootstrapStats] = React.useState(null); const [historyOpen, setHistoryOpen] = React.useState(false); React.useEffect(() => { if (!brand) return; CR_API.getBrandBootstrapStats(brand).then(setBootstrapStats).catch(() => setBootstrapStats(null)); }, [brand]); const tgChannel = brandMeta.channel || "@" + brand; // Load real data on first mount + when switching tabs or brand. const reload = React.useCallback(async () => { if (!brand) return; try { const [b, s, st, src, statsResp] = await Promise.all([ CR_API.getBrandYaml(brand).catch(() => null), CR_API.getDoc(brand, "settings").catch(() => null), CR_API.getDoc(brand, "style").catch(() => null), CR_API.getDoc(brand, "sources").catch(() => null), CR_API.getBrandStats(brand).catch(() => null), ]); if (b) setBrandMeta({ ...b, channel: b.channel || "", website: b.website || "", theme_description: b.theme_description || "" }); if (s) setSettingsBuf(s.text || ""); if (st) setStyleBuf(st.text || ""); if (src) setSourcesBuf(src.text || ""); if (statsResp) setStats(statsResp); } catch (e) { console.warn("Settings reload failed:", e.message); } }, [brand]); React.useEffect(() => { reload(); }, [reload, tab]); const saveDoc = async (name, text, label) => { try { await CR_API.putDoc(brand, name, text); onSave && onSave(label); } catch (e) { onSave && onSave(`Ошибка: ${e.message}`); } }; const saveBrandYaml = async () => { try { const updated = await CR_API.putBrandYaml(brand, { channel: brandMeta.channel, timezone: brandMeta.timezone, auto_discover_at: brandMeta.auto_discover_at, website: brandMeta.website || null, theme_description: brandMeta.theme_description || null, }); setBrandMeta({ ...updated, channel: updated.channel || "", website: updated.website || "", theme_description: updated.theme_description || "" }); onSave && onSave("brand.yaml"); } catch (e) { onSave && onSave(`Ошибка: ${e.message}`); } }; return (
Конфиг бренда · {brand}

Настройки

Все правки сохраняются в историю — любую можно откатить.
{SETTINGS_TABS.map((t) => ( ))}
{tab === "brand" && (
О КАНАЛЕ
} onClick={saveBrandYaml}>Сохранить

{brandMeta.slug}

{brandMeta.theme_description ? (

{brandMeta.theme_description}

) : (

Описание тематики появится после первого «Прочитать 100 постов и заполнить» — Claude сгенерирует его на основе постов.

)}
read-only — нельзя менять после создания
setBrandMeta({ ...brandMeta, channel: e.target.value })} placeholder="@username" />
setBrandMeta({ ...brandMeta, timezone: e.target.value })} />
setBrandMeta({ ...brandMeta, auto_discover_at: e.target.value })} placeholder="HH:MM" />
setBrandMeta({ ...brandMeta, website: e.target.value })} placeholder="https://example.com" />
Bootstrap прочитает страницу через Firecrawl и достанет описание/услуги/ЦА. Можно оставить пустым.
АВТОЗАПОЛНЕНИЕ

Заполнить настройки автоматически

Claude прочитает последние 100 опубликованных постов и сам заполнит вкладки «Что искать» и «Как писать» на основе вашего канала.

Внимание: перезапишет существующие настройки и правила стиля. Старые версии остаются в истории — любую можно откатить.
} onClick={() => onBootstrap && onBootstrap(tgChannel, 100)}>Прочитать 100 постов и заполнить setHistoryOpen(true)}>История правок

Статистика

{stats?.subscribers != null ? stats.subscribers.toLocaleString("ru-RU") : (stats?.subscribers_error ? "—" : "…")}
подписчиков
{stats?.subscribers_error && (
{stats.subscribers_error.slice(0, 60)}
)}
{bootstrapStats === null ? (
) : bootstrapStats.has_bootstrap ? ( <>
Паспорт канала
{bootstrapStats.main_topics_count} тем · {bootstrapStats.keywords_count} ключей · {bootstrapStats.content_formats_count} форматов
{bootstrapStats.style_templates_count} шаблона · {bootstrapStats.tg_channels_count + bootstrapStats.web_whitelist_count} источников
{bootstrapStats.posts_analyzed} постов проанализировано
) : ( <>
Паспорт канала
Канал ещё не проанализирован
)}
{stats?.tokens_this_month != null ? stats.tokens_this_month.toLocaleString("ru-RU") : "…"}
токенов · {new Date().toLocaleString("ru-RU", { month: "long" })}
)} {tab === "settings" && (
settings.md
Отменить правки } onClick={() => saveDoc("settings", settingsBuf, "settings.md")}>Сохранить