From dd36d8f1c328f9b51b4e75d78e439c99b4c074be Mon Sep 17 00:00:00 2001 From: Danil Date: Mon, 13 Apr 2026 19:09:04 +0300 Subject: [PATCH] =?UTF-8?q?FurTok:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D1=82=D0=B5=D0=B3=D0=BE=D0=B2,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20uwu,=20TikTok-=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- games/uwu.py | 4 +- webapp/api.py | 3 +- webapp/frontend/css/style.css | 104 ++++++++++++++- webapp/frontend/js/api.js | 2 +- webapp/frontend/js/app.js | 232 ++++++++++++++++++++++++++++------ 5 files changed, 303 insertions(+), 42 deletions(-) diff --git a/games/uwu.py b/games/uwu.py index 9b7db44..6a81614 100644 --- a/games/uwu.py +++ b/games/uwu.py @@ -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 = { diff --git a/webapp/api.py b/webapp/api.py index 65667fe..1cb4133 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -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") diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css index 2ef2e7b..d218b2a 100644 --- a/webapp/frontend/css/style.css +++ b/webapp/frontend/css/style.css @@ -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; diff --git a/webapp/frontend/js/api.js b/webapp/frontend/js/api.js index a3b264b..2d5522f 100644 --- a/webapp/frontend/js/api.js +++ b/webapp/frontend/js/api.js @@ -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 } }), }; diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js index 5157a91..58b2ce6 100644 --- a/webapp/frontend/js/app.js +++ b/webapp/frontend/js/app.js @@ -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 = `
🐺 FurTok
- +
+ + +
+
+
`; @@ -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 = ``; } + const caption = randomCaption(); + card.innerHTML = ` ${mediaHtml}
+
${caption}
⭐ ${post.score} ❤️ ${post.fav_count} @@ -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