bot_tg/webapp/frontend/js/app.js

1066 lines
38 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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) => {
_videoObserver.unobserve(video);
if (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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function initShortiesVideoPlayback(video) {
if (!video) return;
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
if (video.src && !video.src.endsWith('#')) 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: 15,
backBufferLength: 5,
enableWorker: true,
lowLatencyMode: false,
});
hls.loadSource(hlsSrc);
hls.attachMedia(video);
video._hlsInstance = hls;
}
}
// IntersectionObserver: играем только видимое видео, паузим скрытые
const _videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting) {
video.play().catch(() => {});
} else {
video.pause();
}
});
}, { threshold: 0.6 });
function observeVideo(video) {
video.removeAttribute('autoplay');
_videoObserver.observe(video);
}
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 = $(`
<div class="result-overlay">
<div class="result-popup">
<div class="result-icon">${icon}</div>
<div class="result-title">${title}</div>
<div class="result-desc">${desc}</div>
<button class="btn btn-primary">${btnText}</button>
</div>
</div>
`);
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 `<div class="card">${'<div class="skeleton skeleton-line" style="width:' + (60 + Math.random()*35) + '%"></div>'.repeat(lines)}</div>`;
}
// ══════════════════════════════════════════
// PAGE: HOME
// ══════════════════════════════════════════
async function renderHome() {
container.innerHTML = `
<div class="profile-card card">
<div class="skeleton skeleton-circle"></div>
<div class="skeleton skeleton-line" style="width:40%;margin:0 auto"></div>
<div class="skeleton skeleton-line" style="width:60%;margin:8px auto"></div>
</div>
<div class="stat-grid">
<div class="stat-item"><div class="skeleton skeleton-line"></div></div>
<div class="stat-item"><div class="skeleton skeleton-line"></div></div>
</div>
`;
try {
profileData = await API.getProfile();
const p = profileData;
const length = p.length != null ? p.length.toFixed(1) : '—';
container.innerHTML = `
<div class="profile-card card">
<div class="profile-avatar">👤</div>
<div class="profile-name">${p.first_name || p.username || 'Кент'}</div>
<div class="profile-balance">${length} см</div>
<div class="profile-balance-label">текущий размер</div>
</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">${p.active_bets}</div>
<div class="stat-label">Активных ставок</div>
</div>
<div class="stat-item" id="penis-btn" style="cursor:pointer">
<div class="stat-value">🎲</div>
<div class="stat-label">Крутить penis</div>
</div>
</div>
<div class="section-header">Быстрые действия</div>
<div class="quick-actions">
<button class="quick-action" data-action="betting">
<div class="quick-action-icon">⚽</div>
<div class="quick-action-label">Ставки</div>
</button>
<button class="quick-action" data-action="casino">
<div class="quick-action-icon">🎰</div>
<div class="quick-action-label">Казино</div>
</button>
<button class="quick-action" data-action="top">
<div class="quick-action-icon">🏆</div>
<div class="quick-action-label">Топ</div>
</button>
<button class="quick-action" data-action="history">
<div class="quick-action-icon">📊</div>
<div class="quick-action-label">История</div>
</button>
</div>
`;
// Крутить 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 = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Не удалось загрузить профиль<br>${e.message}</div>
</div>
`;
}
}
async function showTop() {
try {
const data = await API.getTop();
await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '<br>'));
} catch (e) {
await showResult('❌', 'Ошибка', e.message);
}
}
async function showBetHistory() {
try {
const data = await API.getBetHistory();
await showResult('📊', 'История ставок', data.text.replace(/\n/g, '<br>'));
} 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 = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
try {
const leagues = await API.getLeagues();
if (leagues.length === 1) {
return renderMatches(leagues[0].alias, leagues[0].name, true);
}
container.innerHTML = `
<div class="section-header">⚽ Выбери лигу</div>
<div class="league-list" id="league-list"></div>
`;
const list = document.getElementById('league-list');
leagues.forEach(l => {
const item = $(`
<div class="league-item">
<span class="league-icon">⚽</span>
<span class="league-name">${l.name}</span>
<span class="league-arrow"></span>
</div>
`);
item.addEventListener('click', () => {
haptic();
navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false });
});
list.appendChild(item);
});
} catch (e) {
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
}
}
async function renderMatches(league, leagueName, isOnlyLeague = false) {
const headerText = isOnlyLeague ? '🎮 Dota 2 Матчи' : leagueName;
const backTarget = isOnlyLeague ? 'home' : 'betting';
const instructionsHtml = isOnlyLeague
? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Ставки на актуальные матчи Dota 2. Выберите исход и укажите сумму ставки в сантиметрах.</div>`
: '';
container.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px">← Назад</button>
<div class="section-header" style="margin:0">${headerText}</div>
</div>
${instructionsHtml}
${skeleton(3)}
`;
document.getElementById('back-btn').onclick = () => navigate(backTarget);
try {
const matches = await API.getMatches(league);
if (!matches.length) {
container.innerHTML += `<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">Нет матчей в этой лиге</div></div>`;
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 = $(`
<div class="card match-card" style="animation-delay:${idx * 0.05}s">
<div class="match-time">${date}</div>
<div class="match-teams">
<div class="match-team">${m.home}</div>
<div class="match-vs">VS</div>
<div class="match-team">${m.away}</div>
</div>
<div class="match-odds">
<button class="odds-btn" data-outcome="1" data-odds="${m.odds_home || 0}">
<span class="odds-label">П1</span>
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
</button>
${m.odds_draw ? `
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
<span class="odds-label">X</span>
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
</button>
` : ''}
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
<span class="odds-label">П2</span>
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
</button>
</div>
</div>
`);
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 += `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
}
}
function showBetSlip() {
if (!selectedBet) return;
document.querySelector('.bet-slip')?.remove();
const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` };
const slip = $(`
<div class="bet-slip visible">
<div class="bet-slip-header">
<span class="card-title">${outcomeLabel[selectedBet.outcome]}</span>
<span class="badge badge-blue">x${selectedBet.odds?.toFixed(2) || '?'}</span>
</div>
<input type="number" class="bet-input" placeholder="Ставка (см)" step="0.1" min="0.1" max="3" value="0.5">
<button class="btn btn-primary" id="place-bet-btn">Поставить</button>
</div>
`);
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 = `
<div class="section-header">🎰 Казино</div>
<div class="card" style="text-align:center;padding:24px">
<div style="font-size:13px;color:var(--text-secondary);margin-bottom:4px">Баланс</div>
<div style="font-size:24px;font-weight:800;color:var(--blue-400)" id="casino-balance">${balance} см</div>
</div>
<div class="card" style="text-align:center;padding:24px">
<div class="slots-display">
<div class="slot-reel" id="reel1">🎰</div>
<div class="slot-reel" id="reel2">🎰</div>
<div class="slot-reel" id="reel3">🎰</div>
</div>
<div class="casino-bet-input">
<input type="number" id="casino-bet" value="0.5" step="0.1" min="0.1" max="1" placeholder="Ставка">
<span style="color:var(--text-secondary);font-size:13px">см</span>
</div>
<button class="btn btn-primary btn-lg" id="spin-btn">🎰 Крутить!</button>
</div>
`;
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 = `
<div class="section-header">📅 Расписание</div>
<div class="schedule-day-tabs" id="day-tabs">
${days.map((d, i) => `<button class="day-tab ${i === todayIdx ? 'active' : ''}" data-day="${i}">${d}</button>`).join('')}
</div>
<div id="lessons-container">${skeleton(4)}</div>
`;
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 = `<div class="empty-state"><div class="empty-state-icon">🎉</div><div class="empty-state-text">Нет пар!</div></div>`;
return;
}
lc.innerHTML = '<div class="card">' + lessons.map(l => `
<div class="lesson-item">
<div class="lesson-number">${l.pair || '—'}</div>
<div class="lesson-info">
<div class="lesson-title">${l.title}</div>
<div class="lesson-time">${l.time_start && l.time_end ? `${l.time_start}${l.time_end}` : ''}</div>
</div>
</div>
`).join('') + '</div>';
} catch (e) {
lc.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
}
}
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 = `
<div class="furtok-header">
<div class="furtok-title" id="furtok-title">${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}</div>
<div class="furtok-header-right">
<div class="furtok-mode-switch" id="furtok-mode-switch">
<button class="furtok-mode-btn ${furtokMode === 'furtok' ? 'active' : ''}" data-mode="furtok">FurTok</button>
<button class="furtok-mode-btn ${furtokMode === 'shorties' ? 'active' : ''}" data-mode="shorties">Shorties</button>
</div>
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
<span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
</label>
<button class="furtok-gear-btn" id="furtok-gear">⚙️</button>
</div>
</div>
<div class="furtok-tags-panel" id="furtok-tags-panel" style="display:none">
<div class="furtok-tags-row">
<input type="text" id="furtok-tags-input" class="furtok-tags-input"
placeholder="rating:safe score:>200 wolf"
value="${furtokCustomTags}">
<button class="furtok-tags-apply" id="furtok-tags-apply">🔄</button>
</div>
<div class="furtok-tags-hint">e621 теги через пробел. Пусто = стандартный запрос + Safe</div>
</div>
<div class="furtok-feed" id="furtok-feed"></div>
`;
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 = `<div class="furtok-loader">Ничего не найдено</div>`;
}
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 = `<img src="${post.url}" loading="lazy" alt="">`;
} 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 = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop playsinline preload="metadata" muted></video>`;
}
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 ? `
<div class="furtok-side-actions">
<button class="furtok-share-btn" type="button" aria-label="Share">🔗 Share</button>
</div>
` : ''}
<div class="furtok-overlay">
<div class="furtok-caption">${caption}</div>
<div class="furtok-stats">
<span>${scoreLabel}</span>
<span>${favLabel}</span>
</div>
</div>
`;
// Тап по видео = 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();
});
observeVideo(video);
}
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);
});
haptic('success');
} catch (e) {
loader.remove();
haptic('error');
if (furtokPage === 1) {
feedEl.innerHTML = `<div class="furtok-loader">❌ ${e.message}</div>`;
}
}
furtokLoading = false;
}
// ══════════════════════════════════════════
// PAGE: CHAT
// ══════════════════════════════════════════
function renderChat() {
container.innerHTML = `
<div class="section-header">💬 ИИ на фене</div>
<div class="chat-messages" id="chat-messages">
${chatHistory.length === 0 ? `
<div class="chat-bubble bot">Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙</div>
` : chatHistory.map(m => `<div class="chat-bubble ${m.role}">${m.text}</div>`).join('')}
</div>
<div class="chat-input-bar">
<input type="text" id="chat-input" placeholder="Напиши сообщение..." autocomplete="off">
<button class="chat-send-btn" id="chat-send">➤</button>
</div>
`;
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 = $(`<div class="chat-bubble user">${text}</div>`);
messages.appendChild(userBubble);
messages.scrollTop = messages.scrollHeight;
// Loading
const loading = $(`<div class="chat-bubble bot" id="chat-loading">💭 печатает...</div>`);
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 = $(`<div class="chat-bubble bot">${res.response}</div>`);
messages.appendChild(botBubble);
messages.scrollTop = messages.scrollHeight;
} catch (e) {
loading.remove();
const errBubble = $(`<div class="chat-bubble bot">Ошибка: ${e.message}</div>`);
messages.appendChild(errBubble);
}
}
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
input.focus();
}
// ── Init ──
navigate('home');