FurTok: редактор тегов, подписи uwu, TikTok-скролл

This commit is contained in:
Danil 2026-04-13 19:09:04 +03:00
parent c2e210c4ae
commit dd36d8f1c3
5 changed files with 303 additions and 42 deletions

View file

@ -94,9 +94,11 @@ async def fetch_uwu_post(tags: str) -> dict:
"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]:
rating = "rating:safe" if safe else "-rating:safe"
tags = f"{rating} animated order:random score:>200"
if extra_tags and extra_tags.strip():
tags = f"{extra_tags.strip()} animated order:random score:>200"
url = "https://e621.net/posts.json"
params = {

View file

@ -389,11 +389,12 @@ 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)
feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
return {"feed": feed}
except Exception as e:
logger.exception("FurTok API failed")

View file

@ -891,6 +891,8 @@ 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; }
@ -924,11 +926,21 @@ body::before {
padding: 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-direction: column;
gap: 8px;
align-items: flex-start;
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;
@ -937,6 +949,7 @@ body::before {
color: rgba(255,255,255,0.85);
}
.furtok-header {
position: absolute;
top: 0;
@ -966,6 +979,31 @@ 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;
@ -998,6 +1036,68 @@ 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;

View file

@ -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) => 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 } }),
};

View file

@ -504,26 +504,78 @@ 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>
<label class="furtok-toggle">
<span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
</label>
<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>
</div>
<div class="furtok-feed" id="furtok-feed"></div>
`;
@ -531,39 +583,151 @@ 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;
furtokPage = 1;
feed.innerHTML = '';
loadFurtokPage(feed);
_furtokReload(feed);
});
// Infinite scroll
feed.addEventListener('scroll', () => {
if (furtokLoading) return;
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) {
furtokPage++;
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();
});
// 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();
}
});
// Первая загрузка
loadFurtokPage(feed);
}
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);
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
loader.remove();
if (!res.feed || res.feed.length === 0) {
@ -585,9 +749,12 @@ async function loadFurtokPage(feedEl) {
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>
@ -606,10 +773,15 @@ async function loadFurtokPage(feedEl) {
}
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');
} catch (e) {
loader.remove();
@ -621,20 +793,6 @@ 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