1035 lines
37 KiB
JavaScript
1035 lines
37 KiB
JavaScript
/**
|
||
* 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 = $(`
|
||
<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);
|
||
}
|
||
|
||
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
||
|
||
try {
|
||
const leagues = await API.getLeagues();
|
||
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 });
|
||
});
|
||
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) {
|
||
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">${leagueName}</div>
|
||
</div>
|
||
${skeleton(3)}
|
||
`;
|
||
document.getElementById('back-btn').onclick = () => navigate('betting');
|
||
|
||
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>
|
||
<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 hasHls = isShorties && Boolean(post.hls_url);
|
||
const videoSrc = hasHls ? '' : (isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url);
|
||
const hlsSrc = isShorties ? escapeHtml(post.hls_url || '') : '';
|
||
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="auto" 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.directSrc && video.src !== video.dataset.directSrc) {
|
||
video.src = video.dataset.directSrc;
|
||
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 = `<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');
|