/* global React, ReactDOM, CR_UI, CR_QUEUE, CR_ARTICLES, CR_POSTS, CR_SETTINGS, CR_JOBLOG, CR_REPORT, CR_API */ const { useState, useEffect, useCallback, useRef } = React; const { Icons, Btn, Modal, Toast } = CR_UI; // Preset rejection reasons offered as quick-pick chips in the reject modal. // UI-only constant — same list for every brand. Per-brand customisation is // out of scope for the MVP; if needed later, move to brand.yaml. const REJECT_REASON_PRESETS = [ "Не интересно", "Уже было", "Не наш тон", "Слабый источник", "Старая новость", "Не про Москву", ]; // Convert backend TopicListItem → UI shape used by queue.jsx mocks. function topicFromApi(t) { return { id: t.id, title: t.title, summary: t.summary, sources: (t.sources || []).map((u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch (e) { return u; } }), keywords: t.keywords || [], found_at: t.found_at ? Date.parse(t.found_at) : Date.now(), rejected_at: t.rejected_at ? Date.parse(t.rejected_at) : null, reason: t.reject_reason, }; } const NAV = [ { id: "queue", label: "Очередь", icon: Icons.Inbox }, { id: "articles", label: "Статьи", icon: Icons.Doc }, { id: "posts", label: "Посты", icon: Icons.Send }, { id: "report", label: "Отчёт", icon: Icons.Info }, { id: "settings", label: "Настройки", icon: Icons.Settings }, ]; const VALID_SCREENS = new Set(["queue", "articles", "posts", "report", "settings"]); // Parse #{brand}/{screen} or legacy #{screen} // Returns { brand: string|null, screen: string } function parseHash() { const raw = (window.location.hash || "").replace(/^#\/?/, ""); if (!raw) return { brand: null, screen: "queue" }; const parts = raw.split("/"); if (parts.length >= 2) { const [b, s] = parts; return { brand: b || null, screen: VALID_SCREENS.has(s) ? s : "queue" }; } // Legacy #{screen} return { brand: null, screen: VALID_SCREENS.has(parts[0]) ? parts[0] : "queue" }; } const App = () => { const [theme, setTheme] = useState(() => localStorage.getItem("cr-theme") || "light"); const [screen, setScreen] = useState(() => parseHash().screen); const [brands, setBrands] = useState([]); const [activeBrand, setActiveBrand] = useState(() => parseHash().brand || localStorage.getItem("cr_active_brand") || null); const [brandDropOpen, setBrandDropOpen] = useState(false); const [newBrandModal, setNewBrandModal] = useState(false); const [newBrandSlug, setNewBrandSlug] = useState(""); const [newBrandChannel, setNewBrandChannel] = useState(""); const [newBrandCreating, setNewBrandCreating] = useState(false); const [queueState, setQueueState] = useState("success"); const [articlesState, setArticlesState] = useState("success"); const [postsState, setPostsState] = useState("success"); const [joblogOpen, setJoblogOpen] = useState(false); const [rejectingTopic, setRejectingTopic] = useState(null); const [refining, setRefining] = useState(false); const [toast, setToast] = useState(null); const [rejectReason, setRejectReason] = useState(""); const [rejectChips, setRejectChips] = useState([]); const [refineText, setRefineText] = useState(""); const [topicCounts, setTopicCounts] = useState({ pending: 0, approved: 0, rejected: 0 }); const [topics, setTopics] = useState({ pending: [], approved: [], rejected: [] }); const [apiOnline, setApiOnline] = useState(null); const [postCounts, setPostCounts] = useState({ drafts: 0, published: 0 }); const [footerStats, setFooterStats] = useState(null); // { tokens_this_month, last_bootstrap_at } or null const [footerYaml, setFooterYaml] = useState(null); // brand.yaml for auto_discover_at const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); // null = idle, {topics,research,posts,total} = ready const [searchLoading, setSearchLoading] = useState(false); const [activeJobs, setActiveJobs] = useState([]); const [pendingNavTarget, setPendingNavTarget] = useState(null); // {kind, id, folder} const dropRef = useRef(null); const searchInputRef = useRef(null); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("cr-theme", theme); }, [theme]); // Load brand list on startup; set activeBrand if not already set or stale useEffect(() => { CR_API.listBrands().then((list) => { setBrands(list); setActiveBrand((cur) => { if (cur && list.includes(cur)) return cur; const first = list[0] || null; if (first) localStorage.setItem("cr_active_brand", first); return first; }); }).catch(() => {}); }, []); // Persist activeBrand to localStorage and update URL hash useEffect(() => { if (!activeBrand) return; localStorage.setItem("cr_active_brand", activeBrand); const expected = `#${activeBrand}/${screen}`; if (window.location.hash !== expected) { window.history.replaceState(null, "", expected); } }, [activeBrand, screen]); // Hash ↔ screen sync — survives F5 and Back/Forward useEffect(() => { const onHashChange = () => { const { brand: b, screen: s } = parseHash(); setScreen((cur) => cur === s ? cur : s); if (b) setActiveBrand((cur) => cur === b ? cur : b); }; window.addEventListener("hashchange", onHashChange); window.addEventListener("popstate", onHashChange); return () => { window.removeEventListener("hashchange", onHashChange); window.removeEventListener("popstate", onHashChange); }; }, []); const goToScreen = useCallback((next) => { if (!VALID_SCREENS.has(next)) return; setActiveBrand((brand) => { const h = brand ? `#${brand}/${next}` : `#${next}`; if (window.location.hash !== h) window.history.pushState(null, "", h); return brand; }); setScreen(next); }, []); // Close brand dropdown on outside click useEffect(() => { if (!brandDropOpen) return; const handler = (e) => { if (dropRef.current && !dropRef.current.contains(e.target)) setBrandDropOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [brandDropOpen]); useEffect(() => { if (toast) { const t = setTimeout(() => setToast(null), 2400); return () => clearTimeout(t); } }, [toast]); const showToast = (msg, hash) => setToast({ msg, hash }); // Fetch topics with AbortController so stale brand's requests can't overwrite const refreshTopics = useCallback(async (brand, signal) => { if (!brand) return; try { const [pending, approved, rejected] = await Promise.all([ CR_API.listTopics(brand, "pending"), CR_API.listTopics(brand, "approved"), CR_API.listTopics(brand, "rejected"), ]); if (signal?.aborted) return; const mapped = { pending: pending.map(topicFromApi), approved: approved.map(topicFromApi), rejected: rejected.map(topicFromApi), }; setTopics(mapped); setTopicCounts({ pending: mapped.pending.length, approved: mapped.approved.length, rejected: mapped.rejected.length }); setApiOnline(true); } catch (e) { if (signal?.aborted) return; console.warn("API offline:", e.message); setApiOnline(false); } }, []); // Re-fetch topics when activeBrand changes; abort previous in-flight requests useEffect(() => { if (!activeBrand) return; const ctrl = new AbortController(); refreshTopics(activeBrand, ctrl.signal); return () => ctrl.abort(); }, [activeBrand, refreshTopics]); // Refresh post counts so sidebar matches PostsScreen lists const refreshPostCounts = useCallback(async (brand) => { if (!brand) return; try { const [d, p] = await Promise.all([ CR_API.listPostDrafts(brand).catch(() => []), CR_API.listPostsPublished(brand).catch(() => []), ]); setPostCounts({ drafts: d.length, published: p.length }); } catch (_) {} }, []); useEffect(() => { if (!activeBrand) return; refreshPostCounts(activeBrand); const t = setInterval(() => refreshPostCounts(activeBrand), 8000); return () => clearInterval(t); }, [activeBrand, refreshPostCounts]); // Footer stats (tokens this month + auto-discover schedule + last bootstrap) useEffect(() => { if (!activeBrand) { setFooterStats(null); setFooterYaml(null); return; } let cancelled = false; Promise.all([ CR_API.getBrandStats(activeBrand).catch(() => null), CR_API.getBrandYaml(activeBrand).catch(() => null), ]).then(([stats, yaml]) => { if (cancelled) return; setFooterStats(stats); setFooterYaml(yaml); }); return () => { cancelled = true; }; }, [activeBrand]); // Poll active jobs for the current brand — used for honest queue/article banners useEffect(() => { if (!activeBrand) { setActiveJobs([]); return; } let cancelled = false; async function load() { try { const list = await CR_API.listJobs(activeBrand, true); if (!cancelled) setActiveJobs(list); } catch (_) {} } load(); const t = setInterval(load, 4000); return () => { cancelled = true; clearInterval(t); }; }, [activeBrand]); // ⌘K / Ctrl+K opens search; Esc closes; "/" focuses when not in input useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setSearchOpen(true); setTimeout(() => searchInputRef.current?.focus(), 0); } else if (e.key === "Escape" && searchOpen) { setSearchOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [searchOpen]); // Debounced backend search useEffect(() => { if (!searchOpen) return; if (!activeBrand) return; const q = searchQuery.trim(); if (!q) { setSearchResults(null); return; } setSearchLoading(true); const t = setTimeout(async () => { try { const r = await CR_API.search(activeBrand, q); setSearchResults(r); } catch (e) { setSearchResults({ query: q, topics: [], research: [], posts: [], total: 0, error: e.message }); } finally { setSearchLoading(false); } }, 200); return () => clearTimeout(t); }, [searchQuery, searchOpen, activeBrand]); const handleApprove = async (t) => { try { await CR_API.approveTopic(activeBrand, t.id); // Try to auto-launch deep_research; the brand-level job picks up all // approved topics that don't have research yet. Skip silently if a // job is already running — it will pick up this topic on its sweep. let researchStarted = false; try { await CR_API.triggerDeepResearch(activeBrand); researchStarted = true; } catch (err) { if (err.status !== 409) throw err; } showToast( `«${t.title.slice(0, 40)}…» одобрено${researchStarted ? " · ресерч запущен" : " · ресерч уже идёт"}`, t.id, ); refreshTopics(activeBrand); } catch (e) { showToast("Ошибка: " + e.message); } }; const handleReject = (t) => { setRejectingTopic(t); setRejectReason(""); setRejectChips([]); }; const submitReject = async () => { const t = rejectingTopic; const reason = rejectReason.trim() || rejectChips.join(", ") || "без причины"; try { await CR_API.rejectTopic(activeBrand, t.id, reason); showToast(`«${t.title.slice(0, 40)}…» отклонено`); refreshTopics(activeBrand); } catch (e) { showToast("Ошибка: " + e.message); } setRejectingTopic(null); }; const handleRestore = async (t) => { try { await CR_API.restoreTopic(activeBrand, t.id); showToast(`«${t.title.slice(0, 40)}…» возвращено в очередь`); refreshTopics(activeBrand); } catch (e) { showToast("Ошибка: " + e.message); } }; const handleOpenArticle = (topic) => { if (topic && topic.id) setPendingNavTarget({ kind: "topic", id: topic.id }); goToScreen("articles"); }; const handleFindNew = async () => { if (!activeBrand) return; setQueueState("loading"); setJoblogOpen(true); try { const job = await CR_API.triggerFindTopics(activeBrand); showToast("Поиск тем запущен · job " + job.id, job.id); // Don't claim success — job runs in background; queue state stays "loading" // until polling sees the topic list change. Reset to neutral after 2s. setTimeout(() => setQueueState("success"), 2000); } catch (e) { if (e.status === 409) { showToast("Уже идёт другой job для этого бренда"); } else { showToast("Ошибка: " + e.message); } setQueueState("error"); } }; const handleRunResearchForTopic = async (topic) => { if (!activeBrand || !topic) return; try { const job = await CR_API.triggerDeepResearch(activeBrand); showToast("Запущен deep_research · job " + job.id, job.id); setJoblogOpen(true); } catch (e) { if (e.status === 409) showToast("Уже идёт другой job для этого бренда"); else showToast("Ошибка: " + e.message); } }; const handleGeneratePost = async (topic) => { if (!activeBrand || !topic?.id) return; try { const job = await CR_API.generatePost(activeBrand, topic.id); showToast("Генерация поста запущена · job " + job.job_id, job.job_id); setJoblogOpen(true); goToScreen("posts"); } catch (e) { if (e.status === 400) showToast("Сначала запустите ресерч — версии нет"); else if (e.status === 409) showToast("Уже идёт другой job для этого бренда"); else showToast("Ошибка: " + e.message); } }; const handleSubmitRefine = async (topic, comment) => { if (!activeBrand || !topic?.id || !comment) { setRefining(false); return; } try { await CR_API.refineResearch(activeBrand, topic.id, comment); showToast("Уточнение запущено — Claude напишет новую версию", topic.id); setJoblogOpen(true); } catch (e) { if (e.status === 404) showToast("Нет ресерча — сначала запустите deep_research"); else if (e.status === 409) showToast("Уже идёт другой job для этого бренда"); else showToast("Ошибка: " + e.message); } setRefining(false); }; const handleBootstrap = async (channel, limit = 100) => { if (!channel) { showToast("Укажи канал в TG"); return; } try { await CR_API.telegramFetchHistory(activeBrand, channel, limit); showToast(`Читаю ${limit} постов канала ${channel}…`); let lastPhase = null; const interval = setInterval(async () => { try { const s = await CR_API.telegramStatus(activeBrand); if (s.phase && s.phase !== lastPhase) { lastPhase = s.phase; if (s.phase === "analyzing") { setJoblogOpen(true); showToast(`Скачано ${s.downloaded} постов · Claude анализирует…`, s.claude_job_id || ""); } else if (s.phase === "downloaded") { showToast(`Скачано ${s.downloaded} постов`, "downloaded"); } } if (s.status === "ok") { clearInterval(interval); if (s.phase === "analyzed") { showToast(`Готово: ${s.downloaded} постов → settings.md и style_guide.md обновлены`, "bootstrap-ok"); } else { showToast(`Скачано ${s.downloaded} постов из ${channel}`, "ok"); } } else if (s.status === "failed") { clearInterval(interval); const where = s.phase === "analyzing" ? "Claude" : "загрузка"; showToast(`Ошибка (${where}): ${s.error || "unknown"}`); } } catch (e) { clearInterval(interval); showToast("Ошибка статуса: " + e.message); } }, 1500); } catch (e) { if (e.status === 409) showToast("Уже идёт фоновая загрузка для этого бренда"); else showToast("Ошибка: " + e.message); } }; const handleSave = async (file) => { const map = { "settings.md": "settings", "style_guide.md": "style", "sources.md": "sources" }; const key = map[file]; if (!key) { showToast(`${file} сохранён · git`, "a3f9c12"); return; } showToast(`${file} сохранён · git`, "локально"); }; const handleCopy = (post) => { if (post && post.text && navigator.clipboard) { navigator.clipboard.writeText(post.text).catch(() => {}); } showToast("Скопировано в буфер обмена"); }; const handlePostsChanged = () => { refreshPostCounts(activeBrand); }; // Active job for the queue banner const findTopicsJob = activeJobs.find((j) => j.kind === "find_topics" && (j.status === "running" || j.status === "queued")); const queueLoading = !!findTopicsJob || queueState === "loading"; const computedQueueState = queueLoading ? "loading" : queueState; // Auto-refresh topics when find_topics job transitions running → done. // Job poller (4s) updates activeJobs; we detect the disappearance of a // previously-running find_topics and refetch pending/approved/rejected. // AbortController guards against brand-switch race: if activeBrand changes // before the fetch resolves, refreshTopics drops the stale response. const prevFindTopicsRunningRef = useRef(false); useEffect(() => { const isRunning = !!findTopicsJob; const justFinished = prevFindTopicsRunningRef.current && !isRunning; prevFindTopicsRunningRef.current = isRunning; if (!justFinished || !activeBrand) return; const ctrl = new AbortController(); refreshTopics(activeBrand, ctrl.signal); return () => ctrl.abort(); }, [findTopicsJob, activeBrand, refreshTopics]); return (
{/* TOPBAR */}
Content Radar
{/* Brand selector */}
{brandDropOpen && (
    {brands.length === 0 && (
  • Нет брендов
  • )} {brands.map((b) => (
  • e.currentTarget.style.background = "var(--surface-hover, #f5f5f5)"} onMouseLeave={(e) => e.currentTarget.style.background = ""}> { setActiveBrand(b); localStorage.setItem("cr_active_brand", b); setBrandDropOpen(false); }}> {b === activeBrand && "✓ "}{b} { e.currentTarget.style.color = "var(--status-error-fg)"; }} onMouseLeave={(e) => { e.currentTarget.style.color = "var(--fg-muted)"; }} onClick={async (e) => { e.stopPropagation(); if (!confirm(`Удалить бренд «${b}» и все его данные? Это необратимо.`)) return; try { await CR_API.deleteBrand(b); const list = await CR_API.listBrands(); setBrands(list); if (activeBrand === b) { const next = list[0] || null; setActiveBrand(next); if (next) localStorage.setItem("cr_active_brand", next); else localStorage.removeItem("cr_active_brand"); } setBrandDropOpen(false); showToast(`Бренд «${b}» удалён`); } catch (err) { showToast("Ошибка: " + err.message); } }}>✕
  • ))}
  • { setBrandDropOpen(false); setNewBrandSlug(""); setNewBrandChannel(""); setNewBrandModal(true); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", color: "var(--accent)" }} onMouseEnter={(e) => e.currentTarget.style.background = "var(--surface-hover, #f5f5f5)"} onMouseLeave={(e) => e.currentTarget.style.background = ""}> + Новый бренд
)}
ОП
{/* SIDEBAR */} {/* MAIN */}
{screen === "queue" && ( setJoblogOpen(true)} onApprove={handleApprove} onReject={handleReject} onRestore={handleRestore} onOpenArticle={handleOpenArticle} onFindNew={handleFindNew} onRefresh={() => refreshTopics(activeBrand)} topicsOverride={topics} /> )} {screen === "articles" && ( setPendingNavTarget(null)} activeJobs={activeJobs} onGeneratePost={handleGeneratePost} onRunResearch={handleRunResearchForTopic} onRefine={(topic) => { setRefining(topic || true); setRefineText(""); }} onOpenJobLog={() => setJoblogOpen(true)} /> )} {screen === "posts" && ( setPendingNavTarget(null)} onCopy={handleCopy} onPostsChanged={handlePostsChanged} /> )} {screen === "report" && ( )} {screen === "settings" && ( )}
{/* JOBLOG */}
setJoblogOpen(!joblogOpen)} brand={activeBrand} anyActive={true} />
{/* REJECT MODAL */} {rejectingTopic && ( setRejectingTopic(null)} footer={ <> setRejectingTopic(null)}>Отмена } onClick={submitReject}>Отклонить } >
«{rejectingTopic.title}»