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)}
:
{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