/* 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 копирует текст в буфер.
setTab("drafts")}>
Черновики {drafts ? drafts.length : "…"}
setTab("published")}>
Опубликованные {published ? published.length : "…"}
{list.map((p) => (
setActiveId(p.topic_id)}>
{p.topic_id}
{p.council_decision && (
{p.council_decision === "approved" ? "✓" : p.council_decision === "failed" ? "⚠" : "✎"}
)}
{new Date(p.created_at).toLocaleDateString("ru-RU")} · v{p.research_version}
))}
{postMeta && !isEmpty && (
{postMeta.topic_id}
{tab === "drafts" && draftVersions.history.length > 0 && (
{
const v = Number(e.target.value);
setSelectedDraftVersion(v === draftVersions.current ? null : v);
}}
>
v{draftVersions.current} · текущая
{[...draftVersions.history].reverse().map((v) => (
v{v}
))}
)}
} 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}
setCouncilModal(null)} aria-label="Close">
{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 }) => (
);
// ---------------------------------------------------------------------------
// 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 (
<>
История правок
} onClick={onClose}>Закрыть
{entries === null && (
Загрузка…
)}
{entries && entries.length === 0 && (
История пуста. Snapshot создаётся автоматически перед каждым запуском «Прочитать 100 постов и заполнить».
)}
{entries && entries.map((e) => (
{new Date(e.created_at).toLocaleString("ru-RU", {
day: "numeric", month: "short", hour: "2-digit", minute: "2-digit",
})}
{e.is_pre_restore && (
перед откатом
)}
{e.channel_at_time || "—"}
{e.files.join(" · ")}
{confirmId === e.id ? (
<>
handleRestore(e.id)}>
{restoring === e.id ? "Откат…" : "Подтвердить откат"}
setConfirmId(null)}>Отмена
>
) : (
}
onClick={() => setConfirmId(e.id)}
>
Откатить
)}
))}
>
);
};
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) => (
setTab(t.id)}>{t.label}
))}
{tab === "brand" && (
О КАНАЛЕ
} onClick={saveBrandYaml}>Сохранить
{brandMeta.slug}
{brandMeta.theme_description ? (
{brandMeta.theme_description}
) : (
Описание тематики появится после первого «Прочитать 100 постов и заполнить» — Claude сгенерирует его на основе постов.
)}
АВТОЗАПОЛНЕНИЕ
Заполнить настройки автоматически
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")}>Сохранить
main_topics — широкие темы для поиска",
"keywords — точечные ключевики, которые Claude использует в поисковых запросах",
"content_formats — какие типы материалов нужны",
"audience — для tone-check на этапе поста",
"exclude — анти-фильтр (что НЕ берём)",
]} />
)}
{tab === "style" && (
style_guide.md
Отменить правки
} onClick={() => saveDoc("style", styleBuf, "style_guide.md")}>Сохранить
tone — голос канала: 2-3 строки",
"structure — каркас типового поста",
"templates — шаблоны для авто-генерации",
"vocabulary — список «использовать / избегать»",
]} />
)}
{tab === "sources" && (
sources.md
Отменить правки
} onClick={() => saveDoc("sources", sourcesBuf, "sources.md")}>Сохранить
tg_channels — каналы для Read-only мониторинга",
"web_whitelist — домены, с которых разрешено брать материалы",
"rss_feeds — фиды, проверяются раз в час",
]} />
)}
setHistoryOpen(false)}
brand={brand}
onRestored={(id, preId) => {
console.log("Bootstrap restored:", id, "pre_restore_id:", preId);
alert("Откат выполнен");
// Re-fetch bootstrap stats card
setBootstrapStats(null);
CR_API.getBrandBootstrapStats(brand).then(setBootstrapStats).catch(() => {});
// Re-fetch all doc contents so editors reflect restored files
reload();
}}
/>
);
};
window.CR_POSTS = { PostsScreen };
window.CR_SETTINGS = { SettingsScreen };