Compare commits
No commits in common. "9216bde63ac0c9ab78a8e24957db42dd5706fea5" and "316d97b2801ce4d33cbdf7bd8b1152ebf6811eab" have entirely different histories.
9216bde63a
...
316d97b280
11 changed files with 70 additions and 372 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,4 +22,3 @@ db/
|
|||
AI/models/
|
||||
|
||||
main_legacy.py
|
||||
docker-compose.override.yaml
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Основные настройки поведения и стиля бота редактируются здесь.
|
||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||
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")))
|
||||
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3"))
|
||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||
SKIP_TOKEN = "<skip>"
|
||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ def _init_bets_db() -> None:
|
|||
conn.commit()
|
||||
|
||||
|
||||
try:
|
||||
_init_bets_db()
|
||||
except Exception:
|
||||
logger.warning("Could not init bets DB at import time (will retry on first use)")
|
||||
_init_bets_db()
|
||||
|
||||
|
||||
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
|
||||
|
|
|
|||
|
|
@ -57,9 +57,6 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
|||
if row is None:
|
||||
return None
|
||||
new_length = round(float(row[0]) + delta, 1)
|
||||
# Не даём уйти ниже 0 при проигрыше
|
||||
if delta < 0 and new_length < 0:
|
||||
new_length = 0.0
|
||||
conn.execute(
|
||||
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||
(new_length, user_id),
|
||||
|
|
@ -71,7 +68,6 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
|
||||
def play_casino(user_id: int, bet: float) -> str:
|
||||
current = get_user_length(user_id)
|
||||
if current is None:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
|||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||
data = await resp.json()
|
||||
text = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
|
|
|||
41
games/uwu.py
41
games/uwu.py
|
|
@ -55,6 +55,28 @@ 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:
|
||||
url = "https://e621.net/posts.json"
|
||||
params = {
|
||||
|
|
@ -94,16 +116,9 @@ async def fetch_uwu_post(tags: str) -> dict:
|
|||
"caption": caption
|
||||
}
|
||||
|
||||
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())
|
||||
async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
|
||||
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"
|
||||
params = {
|
||||
|
|
@ -127,24 +142,18 @@ async def fetch_furtok_feed(safe: bool = True, page: int = 1, extra_tags: str =
|
|||
raise Exception("Не удалось загрузить ленту :(")
|
||||
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", [])
|
||||
feed = []
|
||||
for post in posts:
|
||||
file_url = post.get("file", {}).get("url")
|
||||
ext = post.get("file", {}).get("ext", "")
|
||||
if not file_url or ext not in allowed_exts:
|
||||
if not file_url or ext not in ("gif", "webm", "mp4"):
|
||||
continue
|
||||
sample_url = post.get("sample", {}).get("url") or file_url
|
||||
media_type = "image" if ext in IMAGE_EXTS else "video"
|
||||
feed.append({
|
||||
"url": file_url,
|
||||
"sample": sample_url,
|
||||
"ext": ext,
|
||||
"type": media_type,
|
||||
"score": post.get("score", {}).get("total", 0),
|
||||
"fav_count": post.get("fav_count", 0),
|
||||
})
|
||||
|
|
|
|||
8
main.py
8
main.py
|
|
@ -597,14 +597,11 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
start_today = lesson_start_for(0)
|
||||
end_today = lesson_end_today()
|
||||
if start_today <= now < end_today:
|
||||
next_dt = None
|
||||
for i in range(1, 8):
|
||||
next_day = (day + i) % 7
|
||||
if next_day in lesson_days:
|
||||
next_dt = lesson_start_for(i)
|
||||
break
|
||||
if next_dt is None:
|
||||
return "Следующий урок не найден."
|
||||
delta = next_dt - now
|
||||
total_seconds = int(delta.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
|
|
@ -616,7 +613,6 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
||||
)
|
||||
|
||||
delta = None
|
||||
for i in range(0, 8):
|
||||
next_day = (day + i) % 7
|
||||
if next_day in lesson_days:
|
||||
|
|
@ -625,16 +621,12 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
delta = candidate - now
|
||||
break
|
||||
|
||||
if delta is None:
|
||||
return "Ближайший урок не найден, кайфуй."
|
||||
|
||||
total_seconds = int(delta.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
||||
|
||||
|
||||
def _prepare_magnit_query(value: str) -> str:
|
||||
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
|
||||
value = re.sub(r"[_-]+", " ", value)
|
||||
|
|
|
|||
|
|
@ -8,16 +8,12 @@ REST API для Telegram Mini App. Использует те же SQLite баз
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
from urllib.parse import quote
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
import aiohttp
|
||||
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
||||
from fastapi import FastAPI, Depends, Header, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -393,46 +389,17 @@ async def get_uwu_api(
|
|||
async def get_furtok_feed_api(
|
||||
safe: bool = True,
|
||||
page: int = 1,
|
||||
tags: str = "",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Получить ленту анимированных постов для FurTok."""
|
||||
try:
|
||||
feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
|
||||
feed = await fetch_furtok_feed(safe=safe, page=page)
|
||||
return {"feed": feed}
|
||||
except Exception as e:
|
||||
logger.exception("FurTok API failed")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -891,8 +891,6 @@ body::before {
|
|||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.furtok-feed::-webkit-scrollbar { display: none; }
|
||||
|
|
@ -913,13 +911,9 @@ body::before {
|
|||
|
||||
.furtok-card video,
|
||||
.furtok-card img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.furtok-overlay {
|
||||
|
|
@ -930,21 +924,11 @@ body::before {
|
|||
padding: 16px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
|
@ -953,7 +937,6 @@ body::before {
|
|||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
|
||||
|
||||
.furtok-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -983,31 +966,6 @@ body::before {
|
|||
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"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
|
|
@ -1040,68 +998,6 @@ body::before {
|
|||
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 {
|
||||
height: 100%;
|
||||
scroll-snap-align: start;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ export const API = {
|
|||
getTop: () => api('/api/top'),
|
||||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
|
||||
getFurtokFeed: (safe = true, page = 1) => api(`/api/furtok?safe=${safe}&page=${page}`),
|
||||
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -504,78 +504,26 @@ let furtokSafe = true;
|
|||
let furtokPage = 1;
|
||||
let furtokLoading = false;
|
||||
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() {
|
||||
// Очищаем основной контейнер и создаём полноэкранную обёртку
|
||||
container.innerHTML = '';
|
||||
|
||||
// Удаляем старый wrapper если есть
|
||||
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">🐺 FurTok</div>
|
||||
<div class="furtok-header-right">
|
||||
<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>
|
||||
<label class="furtok-toggle">
|
||||
<span>Safe</span>
|
||||
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
||||
</label>
|
||||
</div>
|
||||
<div class="furtok-feed" id="furtok-feed"></div>
|
||||
`;
|
||||
|
|
@ -583,151 +531,39 @@ function renderFurtok() {
|
|||
|
||||
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');
|
||||
|
||||
// Safe toggle
|
||||
safeToggle.addEventListener('change', () => {
|
||||
furtokSafe = safeToggle.checked;
|
||||
_furtokReload(feed);
|
||||
furtokPage = 1;
|
||||
feed.innerHTML = '';
|
||||
loadFurtokPage(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();
|
||||
// Infinite scroll
|
||||
feed.addEventListener('scroll', () => {
|
||||
if (furtokLoading) return;
|
||||
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) {
|
||||
furtokPage++;
|
||||
loadFurtokPage(feed);
|
||||
}
|
||||
});
|
||||
|
||||
// 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++;
|
||||
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 = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
||||
const res = await API.getFurtokFeed(furtokSafe, furtokPage);
|
||||
loader.remove();
|
||||
|
||||
if (!res.feed || res.feed.length === 0) {
|
||||
|
|
@ -743,18 +579,15 @@ async function loadFurtokPage(feedEl) {
|
|||
card.className = 'furtok-card';
|
||||
|
||||
let mediaHtml = '';
|
||||
if (post.type === 'image' || post.ext === 'gif') {
|
||||
if (post.ext === 'gif') {
|
||||
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
||||
} else {
|
||||
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
||||
}
|
||||
|
||||
const caption = randomCaption();
|
||||
|
||||
card.innerHTML = `
|
||||
${mediaHtml}
|
||||
<div class="furtok-overlay">
|
||||
<div class="furtok-caption">${caption}</div>
|
||||
<div class="furtok-stats">
|
||||
<span>⭐ ${post.score}</span>
|
||||
<span>❤️ ${post.fav_count}</span>
|
||||
|
|
@ -773,15 +606,10 @@ async function loadFurtokPage(feedEl) {
|
|||
}
|
||||
|
||||
feedEl.appendChild(card);
|
||||
furtokCards.push(card);
|
||||
});
|
||||
|
||||
// Автоплей первого видео при первой загрузке
|
||||
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||
const firstVideo = furtokCards[0].querySelector('video');
|
||||
if (firstVideo) firstVideo.play().catch(() => {});
|
||||
}
|
||||
|
||||
// IntersectionObserver для автоплейа
|
||||
setupAutoplay(feedEl);
|
||||
haptic('success');
|
||||
} catch (e) {
|
||||
loader.remove();
|
||||
|
|
@ -793,6 +621,20 @@ async function loadFurtokPage(feedEl) {
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue