/** * FenyaBot Mini App — Main Application * SPA with vanilla JS routing and page modules */ import { API } from './api.js'; // ── Telegram WebApp SDK ── const tg = window.Telegram?.WebApp; if (tg) { tg.ready(); tg.expand(); tg.setHeaderColor('#060a14'); tg.setBackgroundColor('#060a14'); } function haptic(type = 'impact') { try { if (type === 'impact') tg?.HapticFeedback?.impactOccurred('light'); else if (type === 'success') tg?.HapticFeedback?.notificationOccurred('success'); else if (type === 'error') tg?.HapticFeedback?.notificationOccurred('error'); } catch {} } // ── State ── let currentPage = 'home'; let profileData = null; let chatHistory = []; let selectedBet = null; // { league, matchIndex, outcome, odds } const container = document.getElementById('page-container'); const navBtns = document.querySelectorAll('.nav-btn'); // ── Router ── function navigate(page, data = null) { document.querySelectorAll('.furtok-card video').forEach((video) => { if (video && video._hlsInstance) { try { video._hlsInstance.destroy(); } catch {} video._hlsInstance = null; } }); currentPage = page; haptic(); // Убираем оверлеи при смене страницы document.querySelector('.bet-slip')?.remove(); document.querySelector('.result-overlay')?.remove(); document.querySelector('.furtok-wrapper')?.remove(); navBtns.forEach(b => b.classList.toggle('active', b.dataset.page === page)); container.style.animation = 'none'; container.offsetHeight; container.style.animation = 'fadeIn 0.25s ease'; switch (page) { case 'home': renderHome(); break; case 'betting': renderBetting(data); break; case 'casino': renderCasino(); break; case 'schedule': renderSchedule(); break; case 'furtok': renderFurtok(); break; case 'chat': renderChat(); break; default: renderHome(); } } navBtns.forEach(btn => { btn.addEventListener('click', () => navigate(btn.dataset.page)); }); // ── Helpers ── function $(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstChild; } function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function initShortiesVideoPlayback(video) { if (!video) return; const hlsSrc = video.dataset.hlsSrc || ''; if (!hlsSrc) return; const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl'); if (canPlayNativeHls) { video.src = hlsSrc; return; } if (window.Hls && window.Hls.isSupported()) { const hls = new window.Hls({ maxBufferLength: 30, backBufferLength: 30, enableWorker: true, }); hls.loadSource(hlsSrc); hls.attachMedia(video); video._hlsInstance = hls; } } function getShortiesShareUrl(post) { const source = String(post?.source || '').trim(); if (source) return source; return String(post?.url || '').trim(); } function saveShortiesLinkLocally(url) { if (!url) return; const key = 'shorties_saved_links'; let links = []; try { const parsed = JSON.parse(localStorage.getItem(key) || '[]'); if (Array.isArray(parsed)) links = parsed.filter((item) => typeof item === 'string' && item.trim()); } catch {} if (!links.includes(url)) { links.unshift(url); localStorage.setItem(key, JSON.stringify(links.slice(0, 200))); } } async function copyToClipboard(text) { if (!text) return false; try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch {} try { const input = document.createElement('textarea'); input.value = text; input.setAttribute('readonly', ''); input.style.position = 'absolute'; input.style.left = '-9999px'; document.body.appendChild(input); input.select(); const ok = document.execCommand('copy'); document.body.removeChild(input); return !!ok; } catch { return false; } } function showResult(icon, title, desc, btnText = 'OK') { return new Promise(resolve => { const overlay = $(`
${icon}
${title}
${desc}
`); overlay.querySelector('.btn').onclick = () => { overlay.remove(); resolve(); }; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(); } }; document.body.appendChild(overlay); }); } function skeleton(lines = 3) { return `
${'
'.repeat(lines)}
`; } // ══════════════════════════════════════════ // PAGE: HOME // ══════════════════════════════════════════ async function renderHome() { container.innerHTML = `
`; try { profileData = await API.getProfile(); const p = profileData; const length = p.length != null ? p.length.toFixed(1) : '—'; container.innerHTML = `
👤
${p.first_name || p.username || 'Кент'}
${length} см
текущий размер
${p.active_bets}
Активных ставок
🎲
Крутить penis
Быстрые действия
`; // Крутить penis document.getElementById('penis-btn')?.addEventListener('click', async () => { haptic(); try { const res = await API.playPenis(); if (res.success) { haptic('success'); await showResult('🎉', 'Успех!', res.message); } else { await showResult('⏳', 'Подожди', res.message); } renderHome(); } catch (e) { haptic('error'); await showResult('❌', 'Ошибка', e.message); } }); // Quick actions container.querySelectorAll('.quick-action').forEach(btn => { btn.addEventListener('click', () => { const action = btn.dataset.action; if (action === 'betting') navigate('betting'); else if (action === 'casino') navigate('casino'); else if (action === 'top') showTop(); else if (action === 'history') showBetHistory(); }); }); } catch (e) { container.innerHTML = `
⚠️
Не удалось загрузить профиль
${e.message}
`; } } async function showTop() { try { const data = await API.getTop(); await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '
')); } catch (e) { await showResult('❌', 'Ошибка', e.message); } } async function showBetHistory() { try { const data = await API.getBetHistory(); await showResult('📊', 'История ставок', data.text.replace(/\n/g, '
')); } catch (e) { await showResult('❌', 'Ошибка', e.message); } } // ══════════════════════════════════════════ // PAGE: BETTING // ══════════════════════════════════════════ async function renderBetting(data) { if (data?.league) { return renderMatches(data.league, data.leagueName, data.isOnlyLeague); } container.innerHTML = `
⚽ Ставки на матчи
${skeleton(2)}`; try { const leagues = await API.getLeagues(); if (leagues.length === 1) { return renderMatches(leagues[0].alias, leagues[0].name, true); } container.innerHTML = `
⚽ Выбери лигу
`; const list = document.getElementById('league-list'); leagues.forEach(l => { const item = $(`
${l.name}
`); item.addEventListener('click', () => { haptic(); navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false }); }); list.appendChild(item); }); } catch (e) { container.innerHTML = `
${e.message}
`; } } async function renderMatches(league, leagueName, isOnlyLeague = false) { const headerText = isOnlyLeague ? '🎮 Dota 2 Матчи' : leagueName; const backTarget = isOnlyLeague ? 'home' : 'betting'; const instructionsHtml = isOnlyLeague ? `
Ставки на актуальные матчи Dota 2. Выберите исход и укажите сумму ставки в сантиметрах.
` : ''; container.innerHTML = `
${headerText}
${instructionsHtml} ${skeleton(3)} `; document.getElementById('back-btn').onclick = () => navigate(backTarget); try { const matches = await API.getMatches(league); if (!matches.length) { container.innerHTML += `
📭
Нет матчей в этой лиге
`; return; } // Remove skeleton container.querySelectorAll('.card').forEach(c => c.remove()); matches.forEach((m, idx) => { const date = m.commence ? new Date(m.commence).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; const card = $(`
${date}
${m.home}
VS
${m.away}
`); card.querySelectorAll('.odds-btn').forEach(btn => { btn.addEventListener('click', () => { haptic(); selectedBet = { league, matchIndex: m.index, outcome: btn.dataset.outcome, odds: parseFloat(btn.dataset.odds), home: m.home, away: m.away, }; showBetSlip(); }); }); container.appendChild(card); }); } catch (e) { container.innerHTML += `
${e.message}
`; } } function showBetSlip() { if (!selectedBet) return; document.querySelector('.bet-slip')?.remove(); const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` }; const slip = $(`
${outcomeLabel[selectedBet.outcome]} x${selectedBet.odds?.toFixed(2) || '?'}
`); slip.querySelector('#place-bet-btn').addEventListener('click', async () => { const amount = parseFloat(slip.querySelector('.bet-input').value); if (!amount || amount <= 0) return; try { const res = await API.placeBet({ league: selectedBet.league, match_index: selectedBet.matchIndex, outcome: selectedBet.outcome, amount, }); haptic('success'); slip.remove(); await showResult('✅', 'Ставка принята!', res.message); } catch (e) { haptic('error'); await showResult('❌', 'Ошибка', e.message); } }); document.body.appendChild(slip); setTimeout(() => slip.classList.add('visible'), 10); } // ══════════════════════════════════════════ // PAGE: CASINO // ══════════════════════════════════════════ async function renderCasino() { const balance = profileData?.length != null ? profileData.length.toFixed(1) : '?'; container.innerHTML = `
🎰 Казино
Баланс
${balance} см
🎰
🎰
🎰
см
`; document.getElementById('spin-btn').addEventListener('click', async () => { const bet = parseFloat(document.getElementById('casino-bet').value); if (!bet || bet <= 0) return; haptic(); const btn = document.getElementById('spin-btn'); btn.disabled = true; btn.textContent = '⏳ Крутим...'; // Анимация вращения const symbols = ['🍒', '🍋', '🔔', '💎', '7️⃣', '🍀']; const reels = [document.getElementById('reel1'), document.getElementById('reel2'), document.getElementById('reel3')]; reels.forEach(r => r.classList.add('spinning')); const spinInterval = setInterval(() => { reels.forEach(r => { r.textContent = symbols[Math.floor(Math.random() * symbols.length)]; }); }, 100); try { const res = await API.playCasino(bet); clearInterval(spinInterval); // Останавливаем слоты по одному for (let i = 0; i < 3; i++) { await new Promise(r => setTimeout(r, 300)); reels[i].classList.remove('spinning'); reels[i].textContent = res.reels[i]; if (res.won) reels[i].classList.add('won'); haptic(); } // Обновляем баланс document.getElementById('casino-balance').textContent = `${res.new_length.toFixed(1)} см`; if (profileData) profileData.length = res.new_length; await new Promise(r => setTimeout(r, 500)); if (res.won) { haptic('success'); const jackpot = res.matches === 3; await showResult( jackpot ? '🎉' : '✅', jackpot ? 'ДЖЕКПОТ!!!' : 'Выигрыш!', `${jackpot ? 'Три одинаковых!' : 'Две совпали!'}\nx${res.multiplier}\n+${res.delta.toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см` ); } else { haptic('error'); await showResult('❌', 'Мимо!', `−${Math.abs(res.delta).toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см`); } } catch (e) { clearInterval(spinInterval); reels.forEach(r => r.classList.remove('spinning')); haptic('error'); await showResult('❌', 'Ошибка', e.message); } btn.disabled = false; btn.textContent = '🎰 Крутить!'; reels.forEach(r => r.classList.remove('won')); }); } // ══════════════════════════════════════════ // PAGE: SCHEDULE // ══════════════════════════════════════════ async function renderSchedule() { const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const today = new Date().getDay(); const todayIdx = today === 0 ? 6 : today - 1; container.innerHTML = `
📅 Расписание
${days.map((d, i) => ``).join('')}
${skeleton(4)}
`; async function loadDay(dayIdx) { const lc = document.getElementById('lessons-container'); lc.innerHTML = skeleton(3); try { const lessons = await API.getSchedule(dayIdx); if (!lessons.length) { lc.innerHTML = `
🎉
Нет пар!
`; return; } lc.innerHTML = '
' + lessons.map(l => `
${l.pair || '—'}
${l.title}
${l.time_start && l.time_end ? `${l.time_start} — ${l.time_end}` : ''}
`).join('') + '
'; } catch (e) { lc.innerHTML = `
${e.message}
`; } } document.getElementById('day-tabs').addEventListener('click', (e) => { const tab = e.target.closest('.day-tab'); if (!tab) return; haptic(); document.querySelectorAll('.day-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); loadDay(parseInt(tab.dataset.day)); }); loadDay(todayIdx); } // ══════════════════════════════════════════ // PAGE: FURTOK // ══════════════════════════════════════════ let furtokSafe = true; let furtokPage = 1; let furtokLoading = false; let furtokWrapper = null; let furtokCurrentIndex = 0; let furtokCards = []; let furtokCustomTags = ''; let furtokMode = 'furtok'; // "furtok" | "shorties" // Подписи из uwu.py — рандомно появляются на карточках const FURTOK_CAPTIONS = [ "Ня :3", "uwu", "OwO", "Мурк :3", "Мяу~", "Фыр-фыр", "Лапки! 🐾", "Какой пушистик! :3", "Милота!", "Гав!", "Ауф", "Няшно!", "Смотри какая прелесть", "owo what's this", "Твой пушистый друг", "🦊", "🐶", "🐱", "мур-мяу", "пуньк", "кусь", ">w<", "^w^", "Хвостатый сюрприз", "Держи пушистика :3", "Оуо", "Ави!", "UwU", "~nya", "Господи, опять e621...", "Осуждаю, но смотрю", "Мама, я фурри", "Держи своего кринж-пушистика", "Надеюсь, тебе не стыдно", "Товарищ майор уже выехал", "Слишком много интернета на сегодня", "Для этого и придумали интернет", "*Тяжелый вздох*", "Лучше бы на завод пошел", "Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3", "Удали интернет", "И зачем я это только парсю...", "Смотри, но только никому не рассказывай", "Эххх... uwu...", "В дурке сегодня день открытых дверей", "Я нейросеть, помогите, меня держат в заложниках" ]; function randomCaption() { return FURTOK_CAPTIONS[Math.floor(Math.random() * FURTOK_CAPTIONS.length)]; } function _furtokGetTags() { if (furtokMode === 'shorties') return ''; // Если кастомные теги заданы — используем их (safe управляется юзером через теги) if (furtokCustomTags.trim()) return furtokCustomTags.trim(); // Иначе стандартный запрос с safe-переключателем const rating = furtokSafe ? 'rating:safe' : '-rating:safe'; return `${rating} score:>200`; } function _furtokReload(feed) { furtokPage = 1; furtokCurrentIndex = 0; furtokCards = []; feed.innerHTML = ''; loadFurtokPage(feed); } function _updateFurtokUiMode() { const safeWrap = document.getElementById('furtok-safe-wrap'); const gearBtn = document.getElementById('furtok-gear'); const tagsPanel = document.getElementById('furtok-tags-panel'); const title = document.getElementById('furtok-title'); if (title) { title.textContent = furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'; } if (!safeWrap || !gearBtn || !tagsPanel) return; if (furtokMode === 'shorties') { safeWrap.style.display = 'none'; gearBtn.style.display = 'none'; tagsPanel.style.display = 'none'; return; } gearBtn.style.display = ''; safeWrap.style.display = furtokCustomTags ? 'none' : ''; } function renderFurtok() { container.innerHTML = ''; document.querySelector('.furtok-wrapper')?.remove(); furtokPage = 1; furtokLoading = false; furtokCurrentIndex = 0; furtokCards = []; furtokWrapper = document.createElement('div'); furtokWrapper.className = 'furtok-wrapper'; furtokWrapper.innerHTML = `
${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}
`; document.body.appendChild(furtokWrapper); const feed = document.getElementById('furtok-feed'); const safeToggle = document.getElementById('furtok-safe'); const safeWrap = document.getElementById('furtok-safe-wrap'); const gearBtn = document.getElementById('furtok-gear'); const tagsPanel = document.getElementById('furtok-tags-panel'); const tagsInput = document.getElementById('furtok-tags-input'); const tagsApply = document.getElementById('furtok-tags-apply'); const modeSwitch = document.getElementById('furtok-mode-switch'); _updateFurtokUiMode(); modeSwitch?.addEventListener('click', (e) => { const btn = e.target.closest('.furtok-mode-btn'); if (!btn) return; const nextMode = btn.dataset.mode; if (!nextMode || nextMode === furtokMode) return; haptic(); furtokMode = nextMode; modeSwitch.querySelectorAll('.furtok-mode-btn').forEach((item) => { item.classList.toggle('active', item.dataset.mode === furtokMode); }); _updateFurtokUiMode(); _furtokReload(feed); }); // Safe toggle safeToggle.addEventListener('change', () => { furtokSafe = safeToggle.checked; _furtokReload(feed); }); // Gear button — toggle tags panel gearBtn.addEventListener('click', () => { haptic(); const visible = tagsPanel.style.display !== 'none'; tagsPanel.style.display = visible ? 'none' : 'flex'; if (!visible) tagsInput.focus(); }); // Apply tags tagsApply.addEventListener('click', () => { haptic(); furtokCustomTags = tagsInput.value.trim(); // Если кастомные теги — прячем Safe (юзер сам контролирует rating) _updateFurtokUiMode(); tagsPanel.style.display = 'none'; _furtokReload(feed); }); // Enter в поле тегов tagsInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') tagsApply.click(); }); // Жёсткий свайп как в TikTok setupTikTokScroll(feed); loadFurtokPage(feed); } function setupTikTokScroll(feed) { let touchStartY = 0; let touchDeltaY = 0; let isSwiping = false; feed.addEventListener('touchstart', (e) => { touchStartY = e.touches[0].clientY; touchDeltaY = 0; isSwiping = true; }, { passive: true }); feed.addEventListener('touchmove', (e) => { if (!isSwiping) return; touchDeltaY = e.touches[0].clientY - touchStartY; // Показываем визуальный сдвиг текущей карточки const current = furtokCards[furtokCurrentIndex]; if (current) { const clamped = Math.max(-120, Math.min(120, touchDeltaY)); current.style.transform = `translateY(${clamped}px)`; current.style.transition = 'none'; } }, { passive: true }); feed.addEventListener('touchend', () => { if (!isSwiping) return; isSwiping = false; const current = furtokCards[furtokCurrentIndex]; if (current) { current.style.transform = ''; current.style.transition = 'transform 0.3s ease'; } const threshold = 50; if (touchDeltaY < -threshold && furtokCurrentIndex < furtokCards.length - 1) { // Свайп вверх — следующая furtokCurrentIndex++; scrollToCard(feed); haptic(); // Подгрузка если близко к концу if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) { furtokPage++; loadFurtokPage(feed); } } else if (touchDeltaY > threshold && furtokCurrentIndex > 0) { // Свайп вниз — предыдущая furtokCurrentIndex--; scrollToCard(feed); haptic(); } touchDeltaY = 0; }); // Десктоп: колесо мыши let wheelLock = false; feed.addEventListener('wheel', (e) => { e.preventDefault(); if (wheelLock) return; wheelLock = true; setTimeout(() => { wheelLock = false; }, 400); if (e.deltaY > 0 && furtokCurrentIndex < furtokCards.length - 1) { furtokCurrentIndex++; scrollToCard(feed); if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) { furtokPage++; loadFurtokPage(feed); } } else if (e.deltaY < 0 && furtokCurrentIndex > 0) { furtokCurrentIndex--; scrollToCard(feed); } }, { passive: false }); } function scrollToCard(feed) { const card = furtokCards[furtokCurrentIndex]; if (!card) return; feed.scrollTo({ top: card.offsetTop, behavior: 'smooth' }); // Управляем видео: стопим все, играем текущее furtokCards.forEach((c, i) => { const v = c.querySelector('video'); if (!v) return; if (i === furtokCurrentIndex) { v.play().catch(() => {}); } else { v.pause(); } }); } async function loadFurtokPage(feedEl) { if (furtokLoading) return; furtokLoading = true; const loader = document.createElement('div'); loader.className = 'furtok-loader'; loader.textContent = '⏳ Загрузка...'; feedEl.appendChild(loader); try { const res = furtokMode === 'shorties' ? await API.getShortiesFeed(furtokPage, 8) : await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags); loader.remove(); if (!res.feed || res.feed.length === 0) { if (furtokPage === 1) { feedEl.innerHTML = `
Ничего не найдено
`; } furtokLoading = false; return; } res.feed.forEach(post => { const card = document.createElement('div'); card.className = 'furtok-card'; const isShorties = furtokMode === 'shorties'; const shareUrl = getShortiesShareUrl(post); let mediaHtml = ''; if (post.type === 'image' || post.ext === 'gif') { mediaHtml = ``; } else { const poster = post.sample ? ` poster="${post.sample}"` : ''; const directMp4 = post.mp4_url || post.url || ''; const proxyMp4 = isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url; const proxyHls = isShorties && post.hls_url ? API.proxyShortiesMediaUrl(post.hls_url) : ''; const useHls = isShorties && !directMp4 && Boolean(post.hls_url); const videoSrc = useHls ? '' : proxyMp4; const hlsSrc = isShorties ? escapeHtml(proxyHls) : ''; mediaHtml = ``; } const title = post.title ? escapeHtml(post.title) : ''; const caption = title || randomCaption(); const scoreLabel = furtokMode === 'shorties' ? `👁️ ${escapeHtml(post.views || '—')}` : `⭐ ${escapeHtml(post.score ?? 0)}`; const favLabel = furtokMode === 'shorties' ? `⏱️ ${escapeHtml(post.duration || '—')}` : `❤️ ${escapeHtml(post.fav_count ?? 0)}`; card.innerHTML = ` ${mediaHtml} ${isShorties ? `
` : ''}
${caption}
${scoreLabel} ${favLabel}
`; // Тап по видео = unmute + play/pause const video = card.querySelector('video'); if (video) { if (isShorties) { initShortiesVideoPlayback(video); } video.addEventListener('error', () => { if (isShorties && video.dataset.proxySrc && video.src !== video.dataset.proxySrc) { video.src = video.dataset.proxySrc; video.load(); } }); card.addEventListener('click', () => { video.muted = false; if (video.paused) video.play(); else video.pause(); }); } const shareBtn = card.querySelector('.furtok-share-btn'); if (shareBtn) { shareBtn.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); if (!shareUrl) return; const copied = await copyToClipboard(shareUrl); saveShortiesLinkLocally(shareUrl); if (copied) haptic('success'); else haptic('impact'); shareBtn.textContent = copied ? '✅ Saved' : '💾 Saved'; setTimeout(() => { shareBtn.textContent = '🔗 Share'; }, 1200); }); } feedEl.appendChild(card); furtokCards.push(card); }); // Автоплей первого видео при первой загрузке if (furtokCurrentIndex === 0 && furtokCards.length > 0) { const firstVideo = furtokCards[0].querySelector('video'); if (firstVideo) { firstVideo.muted = true; firstVideo.play().catch(() => {}); } } haptic('success'); } catch (e) { loader.remove(); haptic('error'); if (furtokPage === 1) { feedEl.innerHTML = `
❌ ${e.message}
`; } } furtokLoading = false; } // ══════════════════════════════════════════ // PAGE: CHAT // ══════════════════════════════════════════ function renderChat() { container.innerHTML = `
💬 ИИ на фене
${chatHistory.length === 0 ? `
Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙
` : chatHistory.map(m => `
${m.text}
`).join('')}
`; const input = document.getElementById('chat-input'); const sendBtn = document.getElementById('chat-send'); const messages = document.getElementById('chat-messages'); async function send() { const text = input.value.trim(); if (!text) return; input.value = ''; haptic(); // Add user bubble chatHistory.push({ role: 'user', text }); const userBubble = $(`
${text}
`); messages.appendChild(userBubble); messages.scrollTop = messages.scrollHeight; // Loading const loading = $(`
💭 печатает...
`); messages.appendChild(loading); messages.scrollTop = messages.scrollHeight; try { const res = await API.talk(text); loading.remove(); chatHistory.push({ role: 'bot', text: res.response }); const botBubble = $(`
${res.response}
`); messages.appendChild(botBubble); messages.scrollTop = messages.scrollHeight; } catch (e) { loading.remove(); const errBubble = $(`
Ошибка: ${e.message}
`); messages.appendChild(errBubble); } } sendBtn.addEventListener('click', send); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); input.focus(); } // ── Init ── navigate('home');