Compare commits

..

4 commits

Author SHA1 Message Date
q
9216bde63a Merge pull request 'feature/FenyaBot' (#26) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: #26
2026-04-16 09:19:46 +00:00
Danil
3b29648a8c fix: критические баги, FurTok картинки, image proxy, gitignore
- Удалён дубликат handle_uwu_cmd в uwu.py
- Исправлен UnboundLocalError (delta/next_dt) в main.py
- FurTok: поддержка картинок при кастомном запросе
- Добавлен image proxy /api/proxy-image для e621
- CSS: абсолютное позиционирование медиа в furtok-card
- .gitignore: docker-compose.override.yaml
2026-04-14 22:49:49 +03:00
Danil
dd36d8f1c3 FurTok: редактор тегов, подписи uwu, TikTok-скролл 2026-04-13 19:09:04 +03:00
Danil
c2e210c4ae Исправление критических багов и потенциальных проблем
- Удалена дублирующая функция handle_uwu_cmd в uwu.py (мёртвый код)

- Исправлен краш UnboundLocalError в /zvetok (next_dt и delta могли быть не определены)

- Баланс penis больше не уходит в минус при проигрыше в казино

- talk_handler теперь использует путь БД из config.py вместо своего (разные файлы БД)

- betting.py больше не крашится при импорте если /db/ недоступен

- Таймаут гадания снижен со 120 до 30 секунд (Telegram таймаутит раньше)
2026-04-13 18:50:37 +03:00
11 changed files with 373 additions and 71 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ db/
AI/models/ AI/models/
main_legacy.py main_legacy.py
docker-compose.override.yaml

View file

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
# Основные настройки поведения и стиля бота редактируются здесь. # Основные настройки поведения и стиля бота редактируются здесь.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
SKIP_TOKEN = "<skip>" SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}

View file

@ -59,7 +59,10 @@ def _init_bets_db() -> None:
conn.commit() conn.commit()
_init_bets_db() try:
_init_bets_db()
except Exception:
logger.warning("Could not init bets DB at import time (will retry on first use)")
def remember_user_sport_context(user_id: int, sport_alias: str) -> None: def remember_user_sport_context(user_id: int, sport_alias: str) -> None:

View file

@ -57,6 +57,9 @@ def update_user_length(user_id: int, delta: float) -> float | None:
if row is None: if row is None:
return None return None
new_length = round(float(row[0]) + delta, 1) new_length = round(float(row[0]) + delta, 1)
# Не даём уйти ниже 0 при проигрыше
if delta < 0 and new_length < 0:
new_length = 0.0
conn.execute( conn.execute(
"UPDATE penis_stats SET length = ? WHERE user_id = ?", "UPDATE penis_stats SET length = ? WHERE user_id = ?",
(new_length, user_id), (new_length, user_id),
@ -68,6 +71,7 @@ def update_user_length(user_id: int, delta: float) -> float | None:
return None return None
def play_casino(user_id: int, bet: float) -> str: def play_casino(user_id: int, bet: float) -> str:
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:

View file

@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
} }
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
data = await resp.json() data = await resp.json()
text = data["choices"][0]["message"]["content"].strip() text = data["choices"][0]["message"]["content"].strip()

View file

@ -55,28 +55,6 @@ CAPTIONS = [
"Я нейросеть, помогите, меня держат в заложниках" "Я нейросеть, помогите, меня держат в заложниках"
] ]
async def handle_uwu_cmd(message: types.Message):
args = message.text.split(maxsplit=1)
if len(args) > 1 and args[1].strip() == "-h":
help_msg = (
"🐈 <b>Справка по тегам e621:</b>\n\n"
"Перечислите любые теги через пробел после команды.\n"
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
)
await message.reply(help_msg, parse_mode="HTML")
return
tags = "rating:safe score:>500 -animated"
if len(args) > 1:
tags = args[1].strip()
tags += " order:random score:>300"
async def fetch_uwu_post(tags: str) -> dict: async def fetch_uwu_post(tags: str) -> dict:
url = "https://e621.net/posts.json" url = "https://e621.net/posts.json"
params = { params = {
@ -116,8 +94,15 @@ async def fetch_uwu_post(tags: str) -> dict:
"caption": caption "caption": caption
} }
async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]: async def fetch_furtok_feed(safe: bool = True, page: int = 1, extra_tags: str = "") -> list[dict]:
is_custom = bool(extra_tags and extra_tags.strip())
rating = "rating:safe" if safe else "-rating:safe" rating = "rating:safe" if safe else "-rating:safe"
if is_custom:
# Кастомный запрос: без принудительного animated, поддержка картинок
tags = f"{extra_tags.strip()} order:random score:>200"
else:
# Стандартная лента: только видео
tags = f"{rating} animated order:random score:>200" tags = f"{rating} animated order:random score:>200"
url = "https://e621.net/posts.json" url = "https://e621.net/posts.json"
@ -142,18 +127,24 @@ async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
raise Exception("Не удалось загрузить ленту :(") raise Exception("Не удалось загрузить ленту :(")
data = await resp.json() data = await resp.json()
VIDEO_EXTS = ("gif", "webm", "mp4")
IMAGE_EXTS = ("png", "jpg", "jpeg", "webp")
allowed_exts = VIDEO_EXTS + IMAGE_EXTS if is_custom else VIDEO_EXTS
posts = data.get("posts", []) posts = data.get("posts", [])
feed = [] feed = []
for post in posts: for post in posts:
file_url = post.get("file", {}).get("url") file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "") ext = post.get("file", {}).get("ext", "")
if not file_url or ext not in ("gif", "webm", "mp4"): if not file_url or ext not in allowed_exts:
continue continue
sample_url = post.get("sample", {}).get("url") or file_url sample_url = post.get("sample", {}).get("url") or file_url
media_type = "image" if ext in IMAGE_EXTS else "video"
feed.append({ feed.append({
"url": file_url, "url": file_url,
"sample": sample_url, "sample": sample_url,
"ext": ext, "ext": ext,
"type": media_type,
"score": post.get("score", {}).get("total", 0), "score": post.get("score", {}).get("total", 0),
"fav_count": post.get("fav_count", 0), "fav_count": post.get("fav_count", 0),
}) })

View file

@ -597,11 +597,14 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
start_today = lesson_start_for(0) start_today = lesson_start_for(0)
end_today = lesson_end_today() end_today = lesson_end_today()
if start_today <= now < end_today: if start_today <= now < end_today:
next_dt = None
for i in range(1, 8): for i in range(1, 8):
next_day = (day + i) % 7 next_day = (day + i) % 7
if next_day in lesson_days: if next_day in lesson_days:
next_dt = lesson_start_for(i) next_dt = lesson_start_for(i)
break break
if next_dt is None:
return "Следующий урок не найден."
delta = next_dt - now delta = next_dt - now
total_seconds = int(delta.total_seconds()) total_seconds = int(delta.total_seconds())
days = total_seconds // 86400 days = total_seconds // 86400
@ -613,6 +616,7 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин" f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
) )
delta = None
for i in range(0, 8): for i in range(0, 8):
next_day = (day + i) % 7 next_day = (day + i) % 7
if next_day in lesson_days: if next_day in lesson_days:
@ -621,12 +625,16 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
delta = candidate - now delta = candidate - now
break break
if delta is None:
return "Ближайший урок не найден, кайфуй."
total_seconds = int(delta.total_seconds()) total_seconds = int(delta.total_seconds())
days = total_seconds // 86400 days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600 hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60 minutes = (total_seconds % 3600) // 60
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин" return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
def _prepare_magnit_query(value: str) -> str: def _prepare_magnit_query(value: str) -> str:
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value) value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
value = re.sub(r"[_-]+", " ", value) value = re.sub(r"[_-]+", " ", value)

View file

@ -8,12 +8,16 @@ REST API для Telegram Mini App. Использует те же SQLite баз
import asyncio import asyncio
import logging import logging
import os import os
import hashlib
from urllib.parse import quote
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, Depends, Header, HTTPException import aiohttp
from fastapi import FastAPI, Depends, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
@ -389,17 +393,46 @@ async def get_uwu_api(
async def get_furtok_feed_api( async def get_furtok_feed_api(
safe: bool = True, safe: bool = True,
page: int = 1, page: int = 1,
tags: str = "",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""Получить ленту анимированных постов для FurTok.""" """Получить ленту анимированных постов для FurTok."""
try: try:
feed = await fetch_furtok_feed(safe=safe, page=page) feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
return {"feed": feed} return {"feed": feed}
except Exception as e: except Exception as e:
logger.exception("FurTok API failed") logger.exception("FurTok API failed")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/proxy-image")
async def proxy_image(url: str):
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
allowed = ("static1.e621.net", "static2.e621.net", "static1.e926.net")
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.hostname not in allowed:
raise HTTPException(status_code=403, detail="Forbidden host")
headers = {
"User-Agent": f"FenyaBot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)",
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.content_type or "image/png"
body = await resp.read()
return Response(
content=body,
media_type=content_type,
headers={"Cache-Control": "public, max-age=86400"},
)
except aiohttp.ClientError:
raise HTTPException(status_code=502, detail="Failed to fetch image")
# --- ИИ Чат --- # --- ИИ Чат ---
@app.post("/api/talk") @app.post("/api/talk")

View file

@ -891,6 +891,8 @@ body::before {
overflow-y: scroll; overflow-y: scroll;
scroll-snap-type: y mandatory; scroll-snap-type: y mandatory;
scrollbar-width: none; scrollbar-width: none;
overscroll-behavior: none;
touch-action: none;
} }
.furtok-feed::-webkit-scrollbar { display: none; } .furtok-feed::-webkit-scrollbar { display: none; }
@ -911,9 +913,13 @@ body::before {
.furtok-card video, .furtok-card video,
.furtok-card img { .furtok-card img {
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
display: block;
} }
.furtok-overlay { .furtok-overlay {
@ -924,11 +930,21 @@ body::before {
padding: 16px; padding: 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.7)); background: linear-gradient(transparent, rgba(0,0,0,0.7));
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
justify-content: space-between; gap: 8px;
align-items: flex-start;
pointer-events: none; pointer-events: none;
} }
.furtok-caption {
font-size: 15px;
font-weight: 700;
color: white;
text-shadow: 0 1px 6px rgba(0,0,0,0.8);
max-width: 80%;
line-height: 1.3;
}
.furtok-stats { .furtok-stats {
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -937,6 +953,7 @@ body::before {
color: rgba(255,255,255,0.85); color: rgba(255,255,255,0.85);
} }
.furtok-header { .furtok-header {
position: absolute; position: absolute;
top: 0; top: 0;
@ -966,6 +983,31 @@ body::before {
color: rgba(255,255,255,0.8); color: rgba(255,255,255,0.8);
} }
.furtok-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.furtok-gear-btn {
background: rgba(255,255,255,0.15);
border: none;
border-radius: 50%;
width: 34px;
height: 34px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.furtok-gear-btn:hover,
.furtok-gear-btn:active {
background: rgba(255,255,255,0.3);
}
.furtok-toggle input[type="checkbox"] { .furtok-toggle input[type="checkbox"] {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
@ -998,6 +1040,68 @@ body::before {
transform: translateX(18px); transform: translateX(18px);
} }
/* Tags editor panel */
.furtok-tags-panel {
position: absolute;
top: 48px;
left: 0;
right: 0;
z-index: 6;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.furtok-tags-row {
display: flex;
gap: 8px;
}
.furtok-tags-input {
flex: 1;
padding: 8px 12px;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
background: rgba(255,255,255,0.1);
color: white;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.furtok-tags-input::placeholder {
color: rgba(255,255,255,0.4);
}
.furtok-tags-input:focus {
border-color: var(--accent);
}
.furtok-tags-apply {
padding: 8px 14px;
background: var(--accent);
border: none;
border-radius: 8px;
color: white;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s;
}
.furtok-tags-apply:active {
opacity: 0.7;
}
.furtok-tags-hint {
font-size: 11px;
color: rgba(255,255,255,0.45);
line-height: 1.3;
}
.furtok-loader { .furtok-loader {
height: 100%; height: 100%;
scroll-snap-align: start; scroll-snap-align: start;

View file

@ -41,6 +41,6 @@ export const API = {
getTop: () => api('/api/top'), getTop: () => api('/api/top'),
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`), getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`), getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
getFurtokFeed: (safe = true, page = 1) => api(`/api/furtok?safe=${safe}&page=${page}`), getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }), talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
}; };

View file

@ -504,26 +504,78 @@ let furtokSafe = true;
let furtokPage = 1; let furtokPage = 1;
let furtokLoading = false; let furtokLoading = false;
let furtokWrapper = null; let furtokWrapper = null;
let furtokCurrentIndex = 0;
let furtokCards = [];
let furtokCustomTags = '';
// Подписи из 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() {
// Если кастомные теги заданы — используем их (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 renderFurtok() { function renderFurtok() {
// Очищаем основной контейнер и создаём полноэкранную обёртку
container.innerHTML = ''; container.innerHTML = '';
// Удаляем старый wrapper если есть
document.querySelector('.furtok-wrapper')?.remove(); document.querySelector('.furtok-wrapper')?.remove();
furtokPage = 1; furtokPage = 1;
furtokLoading = false; furtokLoading = false;
furtokCurrentIndex = 0;
furtokCards = [];
furtokWrapper = document.createElement('div'); furtokWrapper = document.createElement('div');
furtokWrapper.className = 'furtok-wrapper'; furtokWrapper.className = 'furtok-wrapper';
furtokWrapper.innerHTML = ` furtokWrapper.innerHTML = `
<div class="furtok-header"> <div class="furtok-header">
<div class="furtok-title">🐺 FurTok</div> <div class="furtok-title">🐺 FurTok</div>
<label class="furtok-toggle"> <div class="furtok-header-right">
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
<span>Safe</span> <span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}> <input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
</label> </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>
<div class="furtok-feed" id="furtok-feed"></div> <div class="furtok-feed" id="furtok-feed"></div>
`; `;
@ -531,39 +583,151 @@ function renderFurtok() {
const feed = document.getElementById('furtok-feed'); const feed = document.getElementById('furtok-feed');
const safeToggle = document.getElementById('furtok-safe'); 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');
// Safe toggle
safeToggle.addEventListener('change', () => { safeToggle.addEventListener('change', () => {
furtokSafe = safeToggle.checked; furtokSafe = safeToggle.checked;
furtokPage = 1; _furtokReload(feed);
feed.innerHTML = '';
loadFurtokPage(feed);
}); });
// Infinite scroll // Gear button — toggle tags panel
feed.addEventListener('scroll', () => { gearBtn.addEventListener('click', () => {
if (furtokLoading) return; haptic();
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) { 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)
safeWrap.style.display = furtokCustomTags ? 'none' : '';
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++; furtokPage++;
loadFurtokPage(feed); 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); 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) { async function loadFurtokPage(feedEl) {
if (furtokLoading) return; if (furtokLoading) return;
furtokLoading = true; furtokLoading = true;
// Лоадер
const loader = document.createElement('div'); const loader = document.createElement('div');
loader.className = 'furtok-loader'; loader.className = 'furtok-loader';
loader.textContent = '⏳ Загрузка...'; loader.textContent = '⏳ Загрузка...';
feedEl.appendChild(loader); feedEl.appendChild(loader);
try { try {
const res = await API.getFurtokFeed(furtokSafe, furtokPage); const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
loader.remove(); loader.remove();
if (!res.feed || res.feed.length === 0) { if (!res.feed || res.feed.length === 0) {
@ -579,15 +743,18 @@ async function loadFurtokPage(feedEl) {
card.className = 'furtok-card'; card.className = 'furtok-card';
let mediaHtml = ''; let mediaHtml = '';
if (post.ext === 'gif') { if (post.type === 'image' || post.ext === 'gif') {
mediaHtml = `<img src="${post.url}" loading="lazy">`; mediaHtml = `<img src="${post.url}" loading="lazy">`;
} else { } else {
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`; mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
} }
const caption = randomCaption();
card.innerHTML = ` card.innerHTML = `
${mediaHtml} ${mediaHtml}
<div class="furtok-overlay"> <div class="furtok-overlay">
<div class="furtok-caption">${caption}</div>
<div class="furtok-stats"> <div class="furtok-stats">
<span> ${post.score}</span> <span> ${post.score}</span>
<span> ${post.fav_count}</span> <span> ${post.fav_count}</span>
@ -606,10 +773,15 @@ async function loadFurtokPage(feedEl) {
} }
feedEl.appendChild(card); feedEl.appendChild(card);
furtokCards.push(card);
}); });
// IntersectionObserver для автоплейа // Автоплей первого видео при первой загрузке
setupAutoplay(feedEl); if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
const firstVideo = furtokCards[0].querySelector('video');
if (firstVideo) firstVideo.play().catch(() => {});
}
haptic('success'); haptic('success');
} catch (e) { } catch (e) {
loader.remove(); loader.remove();
@ -621,20 +793,6 @@ async function loadFurtokPage(feedEl) {
furtokLoading = false; furtokLoading = false;
} }
function setupAutoplay(feedEl) {
const videos = feedEl.querySelectorAll('.furtok-card video');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.play().catch(() => {});
} else {
entry.target.pause();
}
});
}, { root: feedEl, threshold: 0.6 });
videos.forEach(v => observer.observe(v));
}
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// PAGE: CHAT // PAGE: CHAT