const { useState, useEffect, useCallback } = React; // --- Telegram SDK ----------------------------------------------------------- const TG = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; const INIT_DATA = TG ? TG.initData : ""; if (TG) { try { TG.ready(); TG.expand(); } catch (e) {} } const ACCENT = "#59A4FF"; const api = (path) => fetch(path).then((r) => r.json()); // Градиент обложки коллекции по паре оттенков function cover(hues) { const [a, b] = hues; return `radial-gradient(130% 100% at 15% 0%, oklch(0.62 0.21 ${a} / 0.9), transparent 55%), ` + `linear-gradient(155deg, oklch(0.5 0.21 ${a}), oklch(0.33 0.19 ${b}))`; } // --- Мини-рендер markdown --------------------------------------------------- function renderInline(text, key) { const parts = text.split(/(\*\*[^*]+\*\*)/g).filter(Boolean); return parts.map((p, i) => p.startsWith("**") && p.endsWith("**") ? {p.slice(2, -2)} : {p} ); } function Markdown({ text }) { const blocks = text.split(/\n\n+/); return blocks.map((block, i) => { if (block.startsWith("## ")) { return

{block.slice(3)}

; } if (block.split("\n").every((l) => l.startsWith("- "))) { return ( ); } return

{renderInline(block)}

; }); } // --- Иконки ----------------------------------------------------------------- const IcSearch = () => ; const IcBack = () => ; const IcBookmark = ({ fill, stroke, size = 19 }) => ; // --- Теги ------------------------------------------------------------------- function fmtTag(fmt, formats) { return { t: (formats[fmt] || "") + " " + fmt, bg: "rgba(255,255,255,0.08)", fg: "#C6CEDE" }; } function catTag(cat, cats) { const c = cats[cat] || { hue: 210, emoji: "" }; return { t: (c.emoji || "") + " " + cat, bg: `oklch(0.72 0.17 ${c.hue} / 0.16)`, fg: `oklch(0.82 0.13 ${c.hue})` }; } const Pill = ({ tag, size = 11.5 }) => ( {tag.t} ); // ============================================================================ function App() { const [data, setData] = useState(null); const [screen, setScreen] = useState("intro"); const [stack, setStack] = useState([]); const [ob, setOb] = useState(-1); const [menuOpen, setMenuOpen] = useState(false); const [toast, setToast] = useState(""); const [q, setQ] = useState(""); const [sort, setSort] = useState("new"); const [fMat, setFMat] = useState({}); const [fFmt, setFFmt] = useState({}); const [fCat, setFCat] = useState({}); const [bookmarks, setBookmarks] = useState({}); const [studied, setStudied] = useState({}); const [lessons, setLessons] = useState({}); const [curCol, setCurCol] = useState(null); const [curMat, setCurMat] = useState(null); const [activeTc, setActiveTc] = useState(-1); const [loadError, setLoadError] = useState(false); useEffect(() => { api("/api/bootstrap?init_data=" + encodeURIComponent(INIT_DATA)) .then((d) => { setData(d); const toMap = (arr) => arr.reduce((m, x) => ((m[x] = true), m), {}); setBookmarks(toMap(d.state.bookmarks)); setStudied(toMap(d.state.studied)); setLessons(toMap(d.state.lessons)); // Стартовый экран и тур подсказок показываем только один раз — // если пользователь уже их проходил (флаг из БД), сразу открываем ленту. if (d.state.onboarded) { setScreen("feed"); } }) .catch(() => setLoadError(true)); }, []); const markOnboarded = () => { fetch("/api/onboarding-complete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ init_data: INIT_DATA }), }).catch(() => {}); }; const showToast = (msg) => { setToast(msg); clearTimeout(window._tt); window._tt = setTimeout(() => setToast(""), 1600); }; const go = (s) => { setStack((p) => [...p, screen]); setScreen(s); setMenuOpen(false); }; const back = () => { const prev = stack.length ? stack[stack.length - 1] : "feed"; setStack((p) => p.slice(0, -1)); setScreen(prev); }; const closeApp = () => { if (TG) { TG.close(); } setScreen("intro"); setStack([]); setOb(-1); }; // Переключение статуса с синхронизацией на бэкенд const toggle = (kind, id, setter) => { setter((p) => ({ ...p, [id]: !p[id] })); fetch("/api/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ init_data: INIT_DATA, kind, id }) }).catch(() => {}); }; const openMaterial = (id) => { setActiveTc(-1); api("/api/material/" + id) .then((m) => { if (m && !m.error) { setCurMat(m); go("material"); } else { showToast("Материал недоступен"); } }) .catch(() => showToast("Не удалось загрузить материал")); }; const openCollection = (col) => { if (col.is_course && data.courses[col.id]) { setCurCol(col); go("course"); } else { setCurCol(col); go("collection"); } }; if (loadError) { return
⚠️
Не удалось загрузить данные
; } if (!data) { return
Загрузка…
; } const { categories, formats, collections, materials, courses } = data; // --- Фильтрация ленты --- const query = q.toLowerCase(); let feed = materials.filter((m) => { if (fMat.bm && !bookmarks[m.id]) return false; if (fMat.done && !studied[m.id]) return false; if (fMat.new && studied[m.id]) return false; const fs = Object.keys(fFmt).filter((k) => fFmt[k]); if (fs.length && !fs.includes(m.format)) return false; const cs = Object.keys(fCat).filter((k) => fCat[k]); if (cs.length && !m.categories.some((x) => cs.includes(x))) return false; if (query && !(m.title + " " + m.description).toLowerCase().includes(query)) return false; return true; }); feed = [...feed].sort((a, b) => (a.date < b.date ? 1 : -1)); if (sort === "old") feed.reverse(); const activeChips = []; if (fMat.bm) activeChips.push({ t: "📌 В закладках", clear: () => setFMat((p) => ({ ...p, bm: false })) }); if (fMat.done) activeChips.push({ t: "✓ Изучен", clear: () => setFMat((p) => ({ ...p, done: false })) }); if (fMat.new) activeChips.push({ t: "○ Не изучен", clear: () => setFMat((p) => ({ ...p, new: false })) }); Object.keys(fFmt).forEach((f) => fFmt[f] && activeChips.push({ t: formats[f] + " " + f, clear: () => setFFmt((p) => ({ ...p, [f]: false })) })); Object.keys(fCat).forEach((c) => fCat[c] && activeChips.push({ t: categories[c].emoji + " " + c, clear: () => setFCat((p) => ({ ...p, [c]: false })) })); const hasFilters = activeChips.length > 0 || sort !== "new"; const resetAll = () => { setFMat({}); setFFmt({}); setFCat({}); setSort("new"); setQ(""); }; // --- Карточка материала в списке --- const MaterialCard = (m) => { const tags = [fmtTag(m.format, formats), ...m.categories.map((c) => catTag(c, categories))]; const on = bookmarks[m.id]; return (
openMaterial(m.id)} style={{ position: "relative", background: "#141C2D", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 18, padding: "14px 44px 14px 14px", cursor: "pointer" }}>
{tags.map((t, i) => )}
{m.title}
{m.description}
); }; const CollectionCard = (col, big) => (
openCollection(col)} style={{ position: "relative", flex: big ? undefined : "none", width: big ? undefined : 132, height: big ? undefined : 172, aspectRatio: big ? "0.82" : undefined, borderRadius: big ? 18 : 16, overflow: "hidden", background: cover(col.hues), cursor: "pointer", boxShadow: "0 8px 20px rgba(0,0,0,0.35)" }}>
{col.count}
{col.emoji}
{col.title}
); const neutral = (on) => on ? { bg: "rgba(89,164,255,0.16)", fg: "#8CC0FF", bd: "rgba(89,164,255,0.45)" } : { bg: "#161E30", fg: "#C6CEDE", bd: "transparent" }; const ChipBtn = ({ t, on, click, style }) => ( ); const OB = [ { t: "Используйте теги", d: "Фильтруйте материалы по формату и темам.", top: "212px", ax: "32px" }, { t: "Сортируйте", d: "По дате публикации — от новых к старым и наоборот.", top: "168px", ax: "300px" }, { t: "Ищите", d: "Умный поиск по всей базе материалов.", top: "168px", ax: "80px" }, { t: "Изучайте", d: "Статьи, видео, стримы и курсы — всё в одном месте.", top: "492px", ax: "56px" }, ]; const obCur = ob >= 0 ? OB[ob] : null; const sectionLabel = (t) =>
{t}
; // ========================= РЕНДЕР ========================= return (
{/* Фоновый паттерн */}
{/* Хедер */}
База знаний
мини-приложение
{/* ЛЕНТА */} {screen !== "intro" && screen === "feed" && (
setQ(e.target.value)} style={{ flex: 1, background: "none", border: "none", color: "#EDF1F9", fontSize: 15, fontFamily: "inherit", minWidth: 0 }} />
{activeChips.length > 0 && (
{activeChips.map((ch, i) => ( ))}
)}
Коллекции
{collections.slice(0, 5).map((c) => CollectionCard(c, false))}
Материалы
{sort === "new" ? "От новых к старым" : "От старых к новым"}
{feed.map(MaterialCard)}
{feed.length === 0 && (
🔍
Ничего не найдено
Попробуйте изменить фильтры или запрос
)}
)} {/* ФИЛЬТР */} {screen === "filter" && (
Фильтры
{sectionLabel("Сортировка")}
{[["new", "От новых к старым"], ["old", "От старых к новым"]].map(([k, t]) => ( ))}
{sectionLabel("Материал")}
{[["bm", "📌 В закладках"], ["done", "✓ Изучен"], ["new", "○ Не изучен"]].map(([k, t]) => ( setFMat((p) => ({ ...p, [k]: !p[k] }))} /> ))}
{sectionLabel("Форматы")}
{Object.keys(formats).map((f) => ( setFFmt((p) => ({ ...p, [f]: !p[f] }))} /> ))}
{sectionLabel("Категории")}
{Object.keys(categories).map((c) => { const h = categories[c].hue, on = !!fCat[c]; return setFCat((p) => ({ ...p, [c]: !p[c] }))} style={{ background: on ? `oklch(0.72 0.17 ${h} / 0.28)` : `oklch(0.72 0.17 ${h} / 0.1)`, color: `oklch(0.84 0.12 ${h})`, border: `1px solid ${on ? `oklch(0.72 0.17 ${h} / 0.6)` : "transparent"}` }} />; })}
)} {/* СЕТКА КОЛЛЕКЦИЙ */} {screen === "collections" && (
Коллекции
{collections.map((c) => CollectionCard(c, true))}
)} {/* ОДНА КОЛЛЕКЦИЯ */} {screen === "collection" && curCol && (
{curCol.emoji}
{curCol.title}
{materials.filter((m) => m.collection === curCol.id).map(MaterialCard)}
)} {/* МАТЕРИАЛ */} {screen === "material" && curMat && (
{[fmtTag(curMat.format, formats), ...curMat.categories.map((c) => catTag(c, categories))].map((t, i) => )}
{curMat.title}
{curMat.timecodes && curMat.timecodes.length > 0 && ( <>
{activeTc >= 0 ? curMat.timecodes[activeTc][0] : curMat.timecodes[0][0]}
{sectionLabel("Содержание")}
{curMat.timecodes.map((tc, i) => (
setActiveTc(i)} style={{ display: "flex", gap: 12, alignItems: "baseline", padding: "10px 8px", borderRadius: 10, cursor: "pointer", background: activeTc === i ? "rgba(89,164,255,0.1)" : "transparent", borderBottom: "1px solid rgba(255,255,255,0.04)" }}> {tc[0]} {tc[1]}
))}
)}
{(() => { const done = studied[curMat.id]; return ( ); })()}
)} {/* КУРС */} {screen === "course" && curCol && courses[curCol.id] && (() => { const course = courses[curCol.id]; const allItems = course.sections.flatMap((s) => s.items); const done = allItems.filter((i) => lessons[i.id]).length; const pct = Math.round((done / allItems.length) * 100); return (
📖 Курс
{curCol.emoji}
{curCol.title}
{course.subtitle}
{pct}% изучено
{course.sections.map((sec, si) => (
{sec.title}
{sec.subtitle &&
{sec.subtitle}
}
{sec.items.map((les) => { const on = !!lessons[les.id]; return (
toggle("lessons", les.id, setLessons)} style={{ flex: "none", width: 24, height: 24, borderRadius: 12, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 800, cursor: "pointer", border: `2px solid ${on ? ACCENT : "#3A4560"}`, background: on ? ACCENT : "transparent", color: "#071120" }}>{on ? "✓" : ""}
{les.title}
{les.material && }
); })}
))}
); })()} {/* INTRO */} {screen === "intro" && (
📚
Материалы базы
Новый формат комьюнити — все материалы в одном месте.
)} {/* ОНБОРДИНГ */} {obCur && (
{obCur.t}
{obCur.d}
{[0, 1, 2, 3].map((i) =>
)}
)} {/* МЕНЮ */} {menuOpen && (
setMenuOpen(false)}>
{[ { t: "Поделиться", icon: "↗", click: () => { setMenuOpen(false); showToast("Ссылка скопирована"); } }, { t: "Перезагрузить страницу", icon: "⟳", click: () => location.reload() }, { t: "Добавить на главный экран", icon: "⊞", click: () => setMenuOpen(false) }, { t: "Условия использования", icon: "§", click: () => setMenuOpen(false) }, { t: "Политика конфиденциальности", icon: "ⓘ", click: () => setMenuOpen(false) }, ].map((mi, i) => (
{mi.t}{mi.icon}
))}
)} {/* TOAST */} {toast && (
{toast}
)}
); } ReactDOM.createRoot(document.getElementById("root")).render();