import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { jwtDecode } from 'jwt-decode'; import axios from 'axios'; import * as Kubikon3DApi from '../api/Kubikon3DService'; import { API_USER_get_my_profile, API_USER_put_birth_date, STORYS_addres, } from '../api/API'; import { useAuth } from '../auth/AuthContext.jsx'; import { KT, cardStyle, buttonPrimary, chipStyle, KUBIKON_KEYFRAMES, skeletonStyle, } from '../utils/kubikonTheme'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import useDeviceType from '../hooks/useDeviceType'; import PleeseReg from '../components/PleeseReg/PleeseReg'; import Icon from '../editor/Icon'; /** * KubikonFeed — главная страница Рублокса. * Современный дизайн: hero-баннер с градиентом, glassmorphism, 3D-tilt * на карточках, плавающие декоративные кубики, badge «🔥 HOT» на топовых. */ // Вкладки умной ленты (RUBLOX_SMART_FEED_PLAN.md, Фаза 6). // id совпадает с параметром tab бэкенда. const SORTS = [ { id: 'recommended', label: 'Рекомендуем' }, { id: 'new', label: 'Новое' }, { id: 'popular', label: 'Популярное' }, { id: 'top_week', label: 'Топ недели' }, ]; const AGE_MODES = [ { id: 'auto', label: 'По возрасту' }, { id: 6, label: '6+' }, { id: 12, label: '12+' }, { id: 16, label: '16+' }, { id: 18, label: '18+' }, { id: 'off', label: 'Все' }, ]; const RATING_FILTERS = [ { id: 0, label: 'Все' }, { id: 50, label: '≥ 50%' }, { id: 70, label: '≥ 70%' }, { id: 90, label: '≥ 90%' }, ]; const AGE_MODE_KEY = 'kubikon_feed_age_mode'; const RATING_FILTER_KEY = 'kubikon_feed_min_rating'; function userAgeToMaxAge(age) { if (age == null) return 12; if (age < 12) return 6; if (age < 16) return 12; if (age < 18) return 16; return 18; } function ageFromBirthDate(iso) { if (!iso) return null; const [y, m, d] = iso.split('-').map(Number); if (!y || !m || !d) return null; const today = new Date(); let age = today.getFullYear() - y; if (today.getMonth() + 1 < m || (today.getMonth() + 1 === m && today.getDate() < d)) { age -= 1; } return age >= 0 && age <= 120 ? age : null; } const KubikonFeed = () => { const navigate = useNavigate(); const { isAuthenticated, isLoading: authLoading } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); // На десктопе у /kubikon есть левое меню сайта — кнопка «Майнкрафтия» // в шапке не нужна. На мобиле/планшете меню школы скрыто, кнопку // оставляем как единственный путь назад. const { isPhone, isTablet } = useDeviceType(); const isMobileLayout = isPhone || isTablet; const userId = (() => { try { const t = localStorage.getItem('Authorization'); if (!t) return null; const p = jwtDecode(t); return p?.id || p?.user_id || null; } catch { return null; } })(); // Гость МОЖЕТ просматривать ленту (с лендинга /). При попытке открыть игру // или открыть студию — показываем модалку с просьбой войти/зарегистрироваться. const [showRegModal, setShowRegModal] = useState(false); const requireAuth = useCallback((action) => { if (isAuthenticated) { action(); } else { setShowRegModal(true); } }, [isAuthenticated]); const [items, setItems] = useState([]); const [totalCount, setTotalCount] = useState(0); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const PER_PAGE = 20; const [searchResults, setSearchResults] = useState(null); const [loading, setLoading] = useState(false); // sort здесь = выбранная вкладка ленты (recommended/new/popular/top_week). const [sort, setSort] = useState(searchParams.get('sort') || 'recommended'); const [searchQ, setSearchQ] = useState(''); const [ageMode, setAgeMode] = useState(() => { try { const saved = localStorage.getItem(AGE_MODE_KEY); if (saved == null) return 'auto'; const n = Number(saved); return Number.isFinite(n) ? n : saved; } catch (e) { return 'auto'; } }); const [minRating, setMinRating] = useState(() => { try { const v = Number(localStorage.getItem(RATING_FILTER_KEY) || 0); return Number.isFinite(v) ? v : 0; } catch (e) { return 0; } }); const [birthDate, setBirthDate] = useState(null); const [profileLoaded, setProfileLoaded] = useState(false); const userAge = ageFromBirthDate(birthDate); const [askDob, setAskDob] = useState(false); useEffect(() => { const tok = localStorage.getItem('Authorization'); if (!tok) { setProfileLoaded(true); return; } axios.get(API_USER_get_my_profile, { headers: { Authorization: tok }, timeout: 6000 }) .then(r => { const bd = r.data?.data?.birthDate ?? r.data?.birthDate ?? null; setBirthDate(bd || null); if (!bd) setAskDob(true); }) .catch(() => { /* ignore */ }) .finally(() => setProfileLoaded(true)); }, []); useEffect(() => { try { localStorage.setItem(AGE_MODE_KEY, String(ageMode)); } catch (e) { /* ignore */ } }, [ageMode]); useEffect(() => { try { localStorage.setItem(RATING_FILTER_KEY, String(minRating)); } catch (e) { /* ignore */ } }, [minRating]); const maxAge = (() => { if (ageMode === 'off') return null; if (ageMode === 'auto') return userAgeToMaxAge(userAge); const n = Number(ageMode); return Number.isFinite(n) ? n : null; })(); const load = useCallback(async () => { setLoading(true); try { // Первая страница. Total — для счётчика; остальные подгружаются // ленивой подгрузкой по скроллу (см. loadMore + IntersectionObserver). const res = await Kubikon3DApi.getFeed( 1, sort, maxAge, minRating > 0 ? minRating : null, { per_page: PER_PAGE } ); const projects = res.data?.projects || []; const total = res.data?.total ?? projects.length; setItems(projects); setTotalCount(total); setPage(1); setHasMore(projects.length < total); } catch (e) { console.error('[KubikonFeed] load error:', e); } setLoading(false); }, [sort, maxAge, minRating]); // Догрузка следующей страницы при достижении низа ленты const loadMore = useCallback(async () => { if (loadingMore || loading || !hasMore) return; setLoadingMore(true); const nextPage = page + 1; try { const res = await Kubikon3DApi.getFeed( nextPage, sort, maxAge, minRating > 0 ? minRating : null, { per_page: PER_PAGE } ); const projects = res.data?.projects || []; const total = res.data?.total ?? totalCount; setItems(prev => { const merged = [...prev, ...projects]; setHasMore(merged.length < total); return merged; }); setTotalCount(total); setPage(nextPage); } catch (e) { console.error('[KubikonFeed] loadMore error:', e); } finally { setLoadingMore(false); } }, [loadingMore, loading, hasMore, page, sort, maxAge, minRating, totalCount]); // IntersectionObserver — догружаем когда последний sentinel виден const sentinelRef = useRef(null); useEffect(() => { if (!hasMore || loading) return; const el = sentinelRef.current; if (!el) return; const obs = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) loadMore(); } }, { rootMargin: '300px 0px' }); obs.observe(el); return () => obs.disconnect(); }, [hasMore, loading, loadMore, items.length]); useEffect(() => { if (!profileLoaded) return; load(); // recommended — вкладка по умолчанию, для неё URL не засоряем. setSearchParams(sort === 'recommended' ? {} : { sort }, { replace: true }); }, [load, sort, setSearchParams, profileLoaded]); useEffect(() => { const q = searchQ.trim(); if (q.length < 2) { setSearchResults(null); return; } let active = true; const t = setTimeout(async () => { try { const res = await Kubikon3DApi.searchProjects(q, maxAge); if (active) setSearchResults(res.data?.projects || []); } catch (e) { /* ignore */ } }, 300); return () => { active = false; clearTimeout(t); }; }, [searchQ, maxAge]); const list = searchResults != null ? searchResults : items; return (
{/* === Модалка для гостя — войди или зарегистрируйся === */} {showRegModal && (
setShowRegModal(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 8, 35, 0.78)', backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, animation: 'fadeIn 200ms ease', }} >
e.stopPropagation()} style={{ background: '#fff', borderRadius: 20, padding: 30, position: 'relative', width: '100%', maxWidth: 460, boxShadow: '0 30px 80px rgba(0,0,0,0.5)', }} >
)} {/* === Sticky header (glass) === */}
{/* Возврат в школу — только на мобиле/планшете, где меню школы скрыто. На десктопе слева есть левое меню сайта, отдельная кнопка не нужна. */} {isMobileLayout && ( Майнкрафтия )} Рублокс Мои игры
{/* === HERO BANNER === */} {!searchResults && !searchQ && ( requireAuth(() => navigate('/'))} /> )} {/* === СКАЧАТЬ ПРИЛОЖЕНИЕ === */} {!searchResults && !searchQ && ( )} {/* === ТЕМАТИЧЕСКИЕ СЕКЦИИ — РАДИКАЛЬНЫЙ РЕДИЗАЙН === */} {!searchResults && !searchQ && profileLoaded && (
{/* Анимированные золотые частицы фона */} {/* Активные события платформы */} navigate(url)} /> {/* Live-statistics — пульсирующая полоса метрик */} {/* Чипы-теги быстрого перехода */} { const el = document.getElementById(id); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }} /> {/* «Продолжить играть» — последняя сыгранная */} {userId && ( requireAuth(() => navigate(`/game/${p.id}`))} /> )} {/* История — недавно играли */} {userId && (
requireAuth(() => navigate(`/game/${p.id}`))} />
)} {/* Избранное — мои сохранённые */} {userId && (
requireAuth(() => navigate(`/game/${p.id}`))} />
)} {/* Спотлайт «Игра дня» — гигантский баннер */} requireAuth(() => navigate(`/game/${p.id}`))} /> {/* Тренды — резкий рост за 14 дней */} {/* Топ-3 на пьедестале */}
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Огненная неделя */}
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Блок «Лучшие игры» — топ по рейтингу умной ленты */}
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Мультиплеер — баннер-разделитель + 3 крупные */}
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Новинки */}
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Жанры — баннер-разделитель */} {/* Жанры в 2 колонки на десктопе, 1 на мобилке */}
requireAuth(() => navigate(`/game/${p.id}`))} />
requireAuth(() => navigate(`/game/${p.id}`))} />
requireAuth(() => navigate(`/game/${p.id}`))} />
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Коллекции от админов */}
requireAuth(() => navigate(`/game/${p.id}`))} />
{/* Топ-авторы недели */}
navigate(`/profile/${uid}`)} />
{/* Лента активности (последние рекорды) */}
requireAuth(() => navigate(`/game/${pid}`))} onUserClick={(uid) => navigate(`/profile/${uid}`)} />
{/* CTA-баннер «Создай свою!» */} requireAuth(() => navigate('/'))} />
)}
{/* Заголовок «Все игры» — отделяет нижнюю секцию ленты от категорий */} {!searchResults && !searchQ && profileLoaded && (

Все игры

)} {/* === Фильтры === */} {!searchResults && (
{SORTS.map(s => ( ))}
{ const n = Number(v); setAgeMode(Number.isFinite(n) && v !== 'auto' && v !== 'off' ? n : v); }} options={AGE_MODES.map(m => ({ value: String(m.id), label: m.label, }))} /> 0 ? `Рейтинг: ≥ ${minRating}%` : 'Рейтинг'} value={String(minRating)} onChange={(v) => setMinRating(Number(v) || 0)} options={RATING_FILTERS.map(r => ({ value: String(r.id), label: r.label, }))} />
)} {searchResults && (
Найдено: {searchResults.length}
)} {/* Section title — зависит от выбранной вкладки ленты */} {!searchResults && !searchQ && !loading && list.length > 0 && (

{sort === 'recommended' ? 'Рекомендуем тебе' : sort === 'new' ? 'Новые игры' : sort === 'top_week' ? 'Топ за неделю' : 'Популярное сейчас'}

)} {/* Подпись-объяснение под заголовком — простыми словами для детей */} {!searchResults && !searchQ && !loading && list.length > 0 && (

{sort === 'recommended' ? 'Игры, которые сейчас больше всего нравятся игрокам — лайки, просмотры и время в игре.' : sort === 'new' ? 'Самые свежие игры — только что опубликованы. Загляни первым!' : sort === 'top_week' ? 'Игры этой недели, в которые играют чаще всего.' : 'Игры с наибольшим числом запусков за всё время.'}

)} {loading ? ( ) : list.length === 0 ? ( ) : ( <>
{list.map((p, i) => ( requireAuth(() => navigate(`/game/${p.id}`))} animateDelay={Math.min(i * 35, 600)} /> ))}
{/* Sentinel + индикатор подгрузки (только в режиме ленты, не в поиске) */} {!searchResults && hasMore && (
{loadingMore ? <> Загружаю ещё... : ' '}
)} {!searchResults && !hasMore && items.length > PER_PAGE && (
Это все игры — {items.length} {pluralRu(items.length, ['игра', 'игры', 'игр'])}
)} )}
{askDob && ( { const tok = localStorage.getItem('Authorization'); if (!tok) { setAskDob(false); return; } try { await axios.put(API_USER_put_birth_date, { birthDate: dob }, { headers: { Authorization: tok }, timeout: 6000 }); setBirthDate(dob); setAskDob(false); } catch (e) { throw e; } }} /> )}
); }; // ================================================================ // === HERO BANNER — вверху ленты, с градиентом и плавающими кубиками === // ================================================================ const HeroBanner = ({ totalGames, onCreate }) => { const { isPhone, isTablet } = useDeviceType(); const compact = isPhone || isTablet; return (
{/* Плавающие кубики (декор) — только на десктопе, на мобиле занимают место и сбивают компоновку */} {!compact && }
Новая платформа

Создавай игры.
Играй с друзьями.

Рублокс — твоя площадка для 3D-игр. Лучший опыт — в нативном приложении на ПК или телефоне.

{totalGames > 0 && (
{totalGames} {pluralRu(totalGames, ['игра', 'игры', 'игр'])} прямо сейчас
)}
{/* Большой логотип справа (плавает) — только на десктопе. На мобиле скрыт, чтобы не съедал ширину. */} {!compact && (
)}
); }; // ================================================================ // === DOWNLOAD APPS BANNER — две кнопки: Windows + Android ====== // ================================================================ // // Большая карточка под hero. Сообщает что: // 1. Браузерная версия будет отключена в будущем — переходим на приложения // 2. Можно скачать .exe для Windows прямо сейчас // 3. Android-версия в работе — кнопка-заглушка с уведомлением «Скоро» // // При клике на «Windows» — открываем прямую ссылку на инсталлер. // При клике на «Android» — модалка «Скоро появится» с предложением // получить уведомление на email. // 2026-05-24: Windows-сборка отложена → кнопка показывает «Скоро». // DOWNLOAD_FALLBACK для Windows больше не нужен, оставляем только Android. // Когда Windows вернётся — восстановить fallback и активную кнопку. const ANDROID_FALLBACK = { apk_url: '', version: '', download_size_mb: 0, }; const DownloadAppsBanner = () => { const { isPhone, isTablet } = useDeviceType(); const compact = isPhone || isTablet; // Состояние модалок «скоро появится» (Windows / Android / iOS). // 2026-05-24: Windows-сборку откладываем — кнопка тоже «Скоро». iOS добавлен. const [comingSoon, setComingSoon] = useState(null); // 'windows' | 'android' | 'ios' | null const [android, setAndroid] = useState(ANDROID_FALLBACK); // Android (отдельный JSON; пустой apk_download_url → кнопка остаётся // в режиме «Скоро»). Windows-fetch удалён вместе с активной кнопкой. useEffect(() => { let alive = true; fetch(`${STORYS_addres}/kubikon3d/native/version-android`) .then((r) => r.ok ? r.json() : null) .then((d) => { if (!alive || !d) return; setAndroid({ apk_url: d.apk_download_url || '', version: d.version || '', download_size_mb: d.download_size_mb || 0, }); }) .catch(() => { /* fallback */ }); return () => { alive = false; }; }, []); const androidAvailable = !!android.apk_url; return (
{/* Декоративные акценты в углах */}
{/* Бейдж важности */}
Важно

Приложение Рублокса — скоро

В браузере не удалось добиться нужного уровня графики, FPS и стабильности — поэтому в ближайшие месяцы веб-версия игры и редактор будут отключены. Полностью переходим на нативные приложения. Приложения для Windows, Android и iOS — в разработке, скоро появятся. Пока что играй в браузере.

{/* Три большие кнопки: Windows / Android / iOS. Все три — пока «Скоро появится». Windows-сборку отложили 2026-05-24 (см. задачу выше). */}
{/* === Windows (заглушка «Скоро») === */} setComingSoon('windows')} /> {/* === Android === */} {androidAvailable ? ( // Активная кнопка — прямая загрузка APK. // Имя файла с версией → браузер не сохранит старую версию из кэша. { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 18px 36px rgba(16, 185, 129, 0.42)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 12px 28px rgba(16, 185, 129, 0.32)'; }} >
Скачать для
Android
{`.apk · ${android.download_size_mb} МБ · v${android.version}`}
) : ( setComingSoon('android')} /> )} {/* === iOS (всегда «Скоро») === */} setComingSoon('ios')} />
{/* Пояснение про браузер — мелкое серое */}
Почему?{' '} В браузере мы упёрлись в потолок: ограничения WebGL по графике, провалы FPS на сложных сценах, частые проблемы со звуком и вводом. В нативном приложении всё это работает стабильно, быстро и красиво — как и должно быть.
{/* Модалка-уведомление «скоро появится» (Windows / Android / iOS) */} {comingSoon && ( setComingSoon(null)} /> )}
); }; /** * Универсальная кнопка-заглушка «Скоро для <платформы>». * Используется на 3 кнопках Windows / Android / iOS в DownloadAppsBanner. */ const ComingSoonPlatformButton = ({ compact, iconName, iconEmoji, iconBg, label, sub, onClick }) => ( ); /** Модалка «<Платформа> скоро появится». Универсальная — параметр * `platform` ('windows' | 'android' | 'ios') определяет иконку и текст. * 2026-05-24: Windows-сборка тоже отложена → теперь все 3 варианта * показывают одну и ту же дружелюбную модалку. */ const PLATFORM_LABELS = { windows: { title: 'Windows-версия скоро!', text: 'Работаем над сборкой для ПК. Скоро будет — следи за обновлениями. ' + 'Пока что играй через браузер.', icon: 'monitor', }, android: { title: 'Android-версия скоро!', text: 'Делаем приложение для Android — выйдет в ближайшие недели. ' + 'Пока что играй в браузере.', icon: 'smartphone', }, ios: { title: 'iOS-версия скоро!', text: 'Приложение для iPhone и iPad в разработке — появится позже. ' + 'Пока что играй в браузере.', icon: 'smartphone', }, }; const PlatformComingSoonModal = ({ platform, onClose }) => { const info = PLATFORM_LABELS[platform] || PLATFORM_LABELS.android; return (
e.stopPropagation()} style={{ background: '#fff', borderRadius: KT.radius2xl, padding: '32px 28px', maxWidth: 440, width: '100%', boxShadow: '0 24px 56px rgba(15, 23, 42, 0.32)', textAlign: 'center', position: 'relative', }} >

{info.title}

{info.text}

); }; /** Плавающие декоративные shapes на фоне hero. */ const FloatingShapes = () => ( <>
); // ================================================================ // === GAME CARD — с 3D-tilt при hover, badge HOT для топов === // ================================================================ const GameCard = ({ project, rank, sort, onClick, animateDelay = 0, showFavorite = true }) => { const [hovered, setHovered] = useState(false); const [tilt, setTilt] = useState({ x: 0, y: 0 }); const [favorited, setFavorited] = useState(false); const ref = useRef(null); const p = project; const isHot = sort === 'top_week' && rank < 3; // Бейдж NEW — умная лента: игра на испытательном окне (quality_state='new'). // Fallback на дату публикации, если бэкенд старый и поле не пришло. const isNew = (() => { if (p.quality_state) return p.quality_state === 'new'; const src = p.published_at || p.created_at; if (!src) return false; const t = new Date(src).getTime(); if (!t) return false; return (Date.now() - t) < 7 * 24 * 3600 * 1000; })(); const userIdMemo = (() => { try { const t = localStorage.getItem('Authorization'); if (!t) return null; const pl = jwtDecode(t); return pl?.id || pl?.user_id || null; } catch { return null; } })(); // Загружаем статус избранного один раз useEffect(() => { if (!showFavorite || !userIdMemo || !p.id) return; let active = true; Kubikon3DApi.getFavoriteStatus(p.id, userIdMemo) .then(r => { if (active) setFavorited(!!r.data?.favorited); }) .catch(() => {}); return () => { active = false; }; }, [p.id, userIdMemo, showFavorite]); const handleFav = (e) => { e.stopPropagation(); e.preventDefault(); if (!userIdMemo || !p.id) return; // Оптимистично setFavorited(prev => !prev); Kubikon3DApi.toggleFavorite(p.id, userIdMemo) .then(r => setFavorited(!!r.data?.favorited)) .catch(() => setFavorited(prev => !prev)); }; const onMouseMove = (e) => { if (!ref.current) return; const rect = ref.current.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width - 0.5; const y = (e.clientY - rect.top) / rect.height - 0.5; // Лёгкий tilt — максимум 6° по осям setTilt({ x: y * -6, y: x * 6 }); }; const onMouseLeave = () => { setHovered(false); setTilt({ x: 0, y: 0 }); }; return (
{ if (e.key === 'Enter') onClick(); }} onMouseEnter={() => setHovered(true)} onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} style={{ background: KT.bgPage, border: `1px solid ${hovered ? KT.accent : KT.border}`, borderRadius: KT.radiusLg, boxShadow: hovered ? KT.shadowLg : KT.shadow, transform: hovered ? `perspective(800px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg) translateY(-6px)` : 'perspective(800px) rotateX(0) rotateY(0) translateY(0)', transition: hovered ? 'transform 100ms ease, box-shadow 280ms ease, border-color 280ms ease' : 'all 300ms cubic-bezier(0.34, 1.56, 0.64, 1)', cursor: 'pointer', overflow: 'hidden', animation: `kubikonFadeIn 460ms cubic-bezier(0.34, 1.56, 0.64, 1) ${animateDelay}ms both`, position: 'relative', willChange: 'transform', }} > {/* Thumbnail */}
{p.thumbnail ? ( {p.title} ) : (
)} {/* Gradient overlay на hover */}
{/* HOT badge */} {isHot && (
HOT #{rank + 1}
)} {/* NEW badge — сверху-слева. Бейдж STAFF PICK убран — рангов в умной ленте больше нет. */}
{isNew && ( NEW )}
{/* Кнопка избранного — сверху справа, рядом с age badge. Используем SVG-иконку чтобы сердечко было симметричным (эмоджи 🤍/❤️ кривые в большинстве шрифтов). */} {showFavorite && userIdMemo && ( )} {/* Age badge */} {p.age_rating || 12}+ {/* Stats overlay снизу */}
{p.rating_percent != null ? `${p.rating_percent}%` : '—'} {(p.play_count || 0).toLocaleString('ru')}
{/* Title block */}
{p.title}
{p.author_username || `#${p.user_id}`}
); }; function ratingColor(pct) { if (pct == null) return KT.textMuted; if (pct >= 85) return KT.success; if (pct >= 60) return KT.warning; return KT.danger; } // ================================================================ // === Search input === // ================================================================ const SearchInput = ({ value, onChange }) => { const [focus, setFocus] = useState(false); return (
onChange(e.target.value)} placeholder="Найти игру…" onFocus={() => setFocus(true)} onBlur={() => setFocus(false)} style={{ width: '100%', boxSizing: 'border-box', background: focus ? KT.bgPage : KT.bgMuted, border: `1px solid ${focus ? KT.accent : 'transparent'}`, borderRadius: 999, padding: '10px 16px 10px 40px', color: KT.text, fontSize: 14, fontFamily: KT.font, outline: 'none', transition: 'all 200ms ease', boxShadow: focus ? KT.shadowGlow : 'none', }} />
); }; // ================================================================ // === FilterSelect (полностью кастомный dropdown) === // ================================================================ const FilterSelect = ({ label, value, onChange, options }) => { const [open, setOpen] = useState(false); const [hovered, setHovered] = useState(false); const wrapRef = useRef(null); // Закрытие при клике вне useEffect(() => { if (!open) return; const onDocClick = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [open]); return (
{/* Trigger pill */} {/* Dropdown menu */} {open && (
{options.map(o => ( { onChange(o.value); setOpen(false); }} /> ))}
)}
); }; const FilterOption = ({ option, selected, onClick }) => { const [hovered, setHovered] = useState(false); return ( ); }; // ================================================================ // === Skeleton grid === // ================================================================ const SkeletonGrid = () => (
{Array.from({ length: 8 }).map((_, i) => (
))}
); // ================================================================ // === Empty state === // ================================================================ const EmptyState = ({ searchMode }) => (
{searchMode ? : }
{searchMode ? 'Ничего не найдено' : 'Лента пока пуста'}
{searchMode ? 'Попробуй другой запрос или сними фильтры' : 'Здесь скоро появятся первые игры от участников Рублокса'}
); function pluralRu(n, forms) { const m10 = n % 10, m100 = n % 100; if (m10 === 1 && m100 !== 11) return forms[0]; if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return forms[1]; return forms[2]; } // ================================================================ // === BirthDate Modal === // ================================================================ const MONTHS_RU = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', ]; const CURRENT_YEAR = new Date().getFullYear(); function daysInMonth(month, year) { if (!month || !year) return 31; return new Date(Number(year), Number(month), 0).getDate(); } function ageDescription(age) { if (age == null) return null; const max = userAgeToMaxAge(age); if (age < 6) return { emoji: '👶', label: 'Малыш', text: 'Доступны только самые мирные игры (6+)' }; if (age < 12) return { emoji: '🧒', label: 'Ребёнок', text: 'Будем показывать игры до 6+' }; if (age < 16) return { emoji: '👦', label: 'Школьник', text: `Будем показывать игры до ${max}+` }; if (age < 18) return { emoji: '🧑', label: 'Подросток', text: 'Будем показывать игры до 16+' }; return { emoji: '🧓', label: 'Взрослый', text: 'Доступен любой контент, включая 18+' }; } const BirthDateModal = ({ onSave }) => { const [day, setDay] = useState(''); const [month, setMonth] = useState(''); const [year, setYear] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [hovered, setHovered] = useState(false); const dValid = day && month && year && Number(day) <= daysInMonth(month, year); const composedISO = dValid ? `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` : ''; const previewAge = composedISO ? ageFromBirthDate(composedISO) : null; const description = ageDescription(previewAge); const canSubmit = composedISO && !submitting; const submit = async () => { if (!canSubmit) return; setSubmitting(true); setError(null); try { await onSave(composedISO); } catch (e) { const msg = e?.response?.data?.message || e?.message || 'Ошибка'; setError(msg); setSubmitting(false); } }; return (

Расскажи свой возраст

Покажем игры, подходящие именно тебе
({ value: String(i + 1), label: String(i + 1) }) )} flex={1} /> ({ value: String(i + 1), label: m, }))} flex={1.6} /> { const y = CURRENT_YEAR - i; return { value: String(y), label: String(y) }; } )} flex={1} />
{description ? (
{description.label} · {previewAge}{' '} {previewAge === 1 ? 'год' : (previewAge >= 2 && previewAge <= 4) ? 'года' : 'лет'}
{description.text}
) : (
Выбери день, месяц и год — мы покажем твой уровень
)} {error && (
{error}
)}
Дата рождения хранится только для возрастной фильтрации. Обработка регулируется{' '} Политикой обработки ПД , на которую ты дал согласие при регистрации.
); }; const DobSelect = ({ label, value, onChange, options, flex }) => (
{label}
); /** * CategorySection — тематический блок на главной ленте Рублокса. * Загружает свой набор проектов через getFeed с заданными params и * рендерит их в одном из 4 layout'ов: * carousel — горизонтальная прокрутка * mosaic — 1 крупная + 4 маленьких (буквой Г) * spotlight — 3 крупные карточки в ряд * grid — компактная сетка 4×2 */ const CategorySection = ({ layout, title, subtitle, accent, emoji, hideHeader, params, onCardClick, }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let active = true; setLoading(true); Kubikon3DApi.getFeed( 1, params.sort || 'popular', params.maxAge != null ? params.maxAge : null, null, { rank: params.rank, multiplayer: params.multiplayer, genre: params.genre, per_page: params.per_page || 10, }, ) .then(r => { if (!active) return; setItems(r.data?.projects || []); }) .catch(() => { if (active) setItems([]); }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [params.rank, params.multiplayer, params.genre, params.sort, params.maxAge, params.per_page]); if (loading) { return (
{!hideHeader && }
{[1, 2, 3, 4].map(i => (
))}
); } if (!items.length) return null; if (layout === 'carousel') { return (
{!hideHeader && } {items.map((p, i) => (
onCardClick(p)} animateDelay={Math.min(i * 30, 300)} />
))}
); } if (layout === 'mosaic') { // 1 крупная слева + 4 маленьких в сетке 2×2 справа const big = items[0]; const smalls = items.slice(1, 5); return (
{!hideHeader && }
onCardClick(big)} />
{smalls.map((p, i) => ( onCardClick(p)} animateDelay={Math.min((i + 1) * 30, 200)} /> ))}
); } if (layout === 'spotlight') { // 3 крупные карточки в ряд const top3 = items.slice(0, 3); return (
{!hideHeader && }
{top3.map((p, i) => ( onCardClick(p)} animateDelay={Math.min(i * 50, 150)} /> ))}
); } // grid (default) return (
{!hideHeader && }
{items.slice(0, 8).map((p, i) => ( onCardClick(p)} animateDelay={Math.min(i * 25, 200)} /> ))}
); }; /** * Заголовок категории — крупный, с эмоджи-бейджем, градиент-текстом * и анимированной разделительной линией. */ const CategoryHeader = ({ title, subtitle, accent, emoji }) => (
{emoji && (
)}

{title}

{subtitle && (
{subtitle}
)}
); /** * Чипы-теги для быстрого перехода к секциям. */ const CategoryChips = ({ onScrollTo }) => { const chips = [ { id: 'sec-history', label: 'Недавние', color: '#06b6d4' }, { id: 'sec-fav', label: 'Избранное', color: '#ef4444' }, { id: 'sec-trending', label: 'Тренды', color: '#fb923c' }, { id: 'sec-top', label: 'Топ', color: '#ffd700' }, { id: 'sec-fire', label: 'Хиты', color: '#ff5e3a' }, { id: 'sec-premium', label: 'Лучшие', color: '#ffb84a' }, { id: 'sec-mp', label: 'Мультиплеер', color: '#5b8bff' }, { id: 'sec-new', label: 'Новинки', color: '#22d97a' }, { id: 'sec-collections', label: 'Подборки', color: '#a855f7' }, { id: 'sec-authors', label: 'Авторы', color: '#eab308' }, { id: 'sec-activity', label: 'Live', color: '#dc2626' }, { id: 'sec-platformer', label: 'Паркур', color: '#9966ff' }, { id: 'sec-puzzle', label: 'Логика', color: '#3b82f6' }, { id: 'sec-action', label: 'Экшен', color: '#ef4444' }, { id: 'sec-other', label: 'Прочее', color: '#14b8a6' }, ]; return (
{chips.map((c, i) => ( ))}
); }; /** * FeaturedBanner — гигантский баннер «Игра дня» с трендовой игрой. * Большая обложка слева, инфо справа, играть-кнопка с пульсацией. */ const FeaturedBanner = ({ params, onCardClick }) => { const [game, setGame] = useState(null); const [hovered, setHovered] = useState(false); useEffect(() => { let active = true; Kubikon3DApi.getFeed( 1, params.sort, params.maxAge, null, { per_page: 1 } ) .then(r => { if (active) setGame((r.data?.projects || [])[0] || null); }) .catch(() => {}); return () => { active = false; }; }, [params.sort, params.maxAge]); if (!game) return null; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onClick={() => onCardClick(game)} > {/* Анимированный градиентный фон */}
{/* Обложка */}
{game.thumbnail ? ( {game.title} ) : (
)} {/* Бейдж «ИГРА ДНЯ» */}
ИГРА ДНЯ
{/* Инфо */}
Самая горячая

{game.title}

{game.description && (

{game.description}

)}
Играть
{(game.play_count || 0).toLocaleString('ru')} {(game.likes_count || 0).toLocaleString('ru')}
); }; /** * PodiumSection — топ-3 на пьедестале (золото/серебро/бронза). * Центральная карточка крупнее, по бокам поменьше — как в спорте. */ const PodiumSection = ({ title, subtitle, params, onCardClick }) => { const [items, setItems] = useState([]); useEffect(() => { let active = true; Kubikon3DApi.getFeed( 1, params.sort, params.maxAge, null, { per_page: 3 } ) .then(r => { if (active) setItems(r.data?.projects || []); }) .catch(() => {}); return () => { active = false; }; }, [params.sort, params.maxAge]); if (items.length < 3) return null; const [first, second, third] = items; const PODIUM = [ { p: second, place: 2, color: '#c0c0c0', size: 'medium', emoji: '🥈', height: 200 }, { p: first, place: 1, color: '#ffd700', size: 'large', emoji: '🥇', height: 240 }, { p: third, place: 3, color: '#cd7f32', size: 'medium', emoji: '🥉', height: 180 }, ]; return (
{PODIUM.map((slot, idx) => ( onCardClick(slot.p)} /> ))}
); }; const PodiumCard = ({ slot, idx, onClick }) => { const [hovered, setHovered] = useState(false); const { p, place, color, emoji, height } = slot; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ cursor: 'pointer', animation: `kubikonFadeInUp 600ms cubic-bezier(0.34, 1.56, 0.64, 1) ${idx * 120}ms both`, position: 'relative', }} > {/* Бейдж места */}
{/* Обложка-карточка */}
{p.thumbnail ? ( {p.title} ) : (
)}
{p.title}
{(p.play_count || 0).toLocaleString('ru')} {(p.likes_count || 0).toLocaleString('ru')}
{/* «Подставка» пьедестала */}
{place}
); }; /** * SectionBanner — широкий горизонтальный баннер-разделитель между секциями. * Гигантский эмоджи + заголовок + градиент-фон + анимация. */ const SectionBanner = ({ emoji, title, subtitle, gradient }) => (
{/* Анимированный декор-кружок */}

{title}

{subtitle && (
{subtitle}
)}
); /** * PremiumSection — секция «Лучшее по рейтингу»: топ игр умной ленты * по hot_score (золотой стиль). Раньше показывала rank2-игры, но рангов * больше нет — теперь это просто самые высоко оценённые игры. */ const PremiumSection = ({ params, onCardClick }) => { const [items, setItems] = useState([]); useEffect(() => { let active = true; // tab=recommended — лента уже отсортирована по hot_score. Kubikon3DApi.getFeed( 1, 'recommended', params.maxAge, null, { per_page: params.per_page } ) .then(r => { if (active) setItems(r.data?.projects || []); }) .catch(() => {}); return () => { active = false; }; }, [params.maxAge, params.per_page]); if (!items.length) return null; return (
{/* Звёзды-декор */} {[...Array(6)].map((_, i) => (
))}
Высший рейтинг

Лучшие игры

Игры, которые игроки оценили выше всего
{items.map((p, i) => ( onCardClick(p)} animateDelay={Math.min(i * 60, 360)} /> ))}
); }; /** * CompactGenreCard — карточка жанра с 4 миниатюрами игр в 2×2. */ const CompactGenreCard = ({ title, subtitle, accent, bgGradient, params, onCardClick }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let active = true; setLoading(true); Kubikon3DApi.getFeed( 1, params.sort, params.maxAge, null, { genre: params.genre, per_page: 4 } ) .then(r => { if (active) setItems(r.data?.projects || []); }) .catch(() => {}) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [params.genre, params.sort, params.maxAge]); if (loading) return null; if (!items.length) return null; return (

{title}

{subtitle}
{items.slice(0, 4).map((p, i) => ( onCardClick(p)} delay={i * 60} /> ))}
); }; /** Мини-карточка для CompactGenreCard. */ const MiniGameCard = ({ project, accent, onClick, delay }) => { const [hovered, setHovered] = useState(false); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ background: KT.bgPage, border: `1.5px solid ${hovered ? accent : KT.border}`, borderRadius: 12, overflow: 'hidden', cursor: 'pointer', transform: hovered ? 'translateY(-3px) scale(1.02)' : 'translateY(0)', boxShadow: hovered ? `0 12px 26px ${accent}33` : '0 2px 6px rgba(0,0,0,0.05)', transition: 'all 240ms cubic-bezier(0.34, 1.56, 0.64, 1)', animation: `kubikonFadeIn 500ms cubic-bezier(0.34, 1.56, 0.64, 1) ${delay}ms both`, }} >
{project.thumbnail ? ( {project.title} ) : (
)}
{project.title}
); }; /** * CreateBanner — призывный CTA-баннер «Создай свою игру». */ const CreateBanner = ({ onClick }) => { const [hovered, setHovered] = useState(false); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ cursor: 'pointer', margin: '48px 0 24px', padding: '40px 32px', background: 'linear-gradient(135deg, #ec4899 0%, #9966ff 35%, #3357ff 100%)', backgroundSize: '200% 200%', borderRadius: 24, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 24, alignItems: 'center', boxShadow: hovered ? '0 30px 80px rgba(153, 102, 255, 0.55)' : '0 16px 40px rgba(153, 102, 255, 0.35)', transform: hovered ? 'translateY(-4px) scale(1.005)' : 'translateY(0)', transition: 'all 320ms cubic-bezier(0.34, 1.56, 0.64, 1)', animation: 'kubikonGradientShift 8s ease-in-out infinite, ' + 'kubikonFadeInScale 700ms cubic-bezier(0.34, 1.56, 0.64, 1)', position: 'relative', overflow: 'hidden', }} > {[...Array(5)].map((_, i) => (
))}
Конструктор

Хочешь создать свою игру?

Открой Кубикон-студию и собери игру из блоков, моделей и скриптов. Опубликуй и набирай рекорды от друзей!

Начать создавать
); }; /** * CarouselScroll — горизонтальная прокрутка с кнопками-стрелками по бокам * и кастомным скролбаром. Кнопки появляются на hover, прячутся если * нет смысла скроллить (всё видно сразу). */ const CarouselScroll = ({ accent = '#3357ff', children }) => { const scrollRef = useRef(null); const [hovered, setHovered] = useState(false); const [canLeft, setCanLeft] = useState(false); const [canRight, setCanRight] = useState(false); const updateButtons = useCallback(() => { const el = scrollRef.current; if (!el) return; setCanLeft(el.scrollLeft > 4); setCanRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4); }, []); useEffect(() => { const el = scrollRef.current; if (!el) return; updateButtons(); const onScroll = () => updateButtons(); el.addEventListener('scroll', onScroll, { passive: true }); const onResize = () => updateButtons(); window.addEventListener('resize', onResize); return () => { el.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onResize); }; }, [updateButtons]); const scrollBy = (dir) => { const el = scrollRef.current; if (!el) return; const dx = el.clientWidth * 0.7 * dir; el.scrollBy({ left: dx, behavior: 'smooth' }); }; const scrollbarClass = `kubikonScroll-${accent.replace('#', '')}`; const scrollbarStyle = ` .${scrollbarClass}::-webkit-scrollbar { height: 10px; } .${scrollbarClass}::-webkit-scrollbar-track { background: rgba(0,0,0,0.05); border-radius: 8px; } .${scrollbarClass}::-webkit-scrollbar-thumb { background: linear-gradient(90deg, ${accent}, ${accent}88); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } .${scrollbarClass}::-webkit-scrollbar-thumb:hover { background: linear-gradient(90deg, ${accent}, ${accent}cc); background-clip: padding-box; } `; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} >
{children}
{/* Кнопка влево */} {canLeft && ( )} {/* Кнопка вправо */} {canRight && ( )}
); }; /** * EventBanner — активные события платформы (Game Jam, конкурсы). */ const EventBanner = ({ onCardClick }) => { const [events, setEvents] = useState([]); useEffect(() => { let active = true; Kubikon3DApi.getEvents() .then(r => { if (!active) return; // Фильтруем закрытые события (Game Jam пока не проводится). // Список id событий которые НЕ показываем независимо от бэка. const HIDDEN = new Set(['create_jam_2026']); const list = (r.data?.events || []) .filter(e => !HIDDEN.has(e.id)); setEvents(list); }) .catch(() => {}); return () => { active = false; }; }, []); if (!events.length) return null; const e = events[0]; return (
e.cta_url && onCardClick(e.cta_url)}>
СОБЫТИЕ

{e.title}

{e.subtitle}
{e.cta_label && (
{e.cta_label}
)}
); }; /** * ResumeCard — большая «Продолжить играть» карточка. */ const ResumeCard = ({ userId, onClick }) => { const [game, setGame] = useState(null); useEffect(() => { let active = true; Kubikon3DApi.getPlayHistory(userId, 1) .then(r => { if (!active) return; const list = r.data?.projects || []; setGame(list[0] || null); }) .catch(() => {}); return () => { active = false; }; }, [userId]); if (!game) return null; return (
onClick(game)} style={{ position: 'relative', zIndex: 1, margin: '0 0 24px', padding: '16px', background: 'linear-gradient(135deg, rgba(6,182,212,0.16), rgba(34,217,122,0.10))', border: '2px solid rgba(6,182,212,0.45)', borderRadius: 18, cursor: 'pointer', display: 'grid', gridTemplateColumns: '120px 1fr auto', gap: 16, alignItems: 'center', animation: 'kubikonFadeInUp 600ms cubic-bezier(0.34, 1.56, 0.64, 1)', transition: 'all 240ms ease', }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 18px 40px rgba(6,182,212,0.30)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
{game.thumbnail ? ( {game.title} ) : (
)}
Продолжить
{game.title}
{game.author_username || '...'} · {game.play_count || 0}
Играть
); }; /** * HistorySection — недавно сыгранные игры пользователя. */ const HistorySection = ({ userId, onCardClick }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let active = true; Kubikon3DApi.getPlayHistory(userId, 10) .then(r => { if (active) setItems(r.data?.projects || []); }) .catch(() => {}) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [userId]); if (loading || items.length === 0) return null; return (
{items.map((p, i) => (
onCardClick(p)} animateDelay={Math.min(i * 30, 200)} />
))}
); }; /** * FavoritesSection — избранные игры пользователя. */ const FavoritesSection = ({ userId, onCardClick }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let active = true; Kubikon3DApi.getMyFavorites(userId) .then(r => { if (active) setItems(r.data?.projects || []); }) .catch(() => {}) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [userId]); if (loading) return null; if (items.length === 0) return null; return (
{items.map((p, i) => (
onCardClick(p)} animateDelay={Math.min(i * 30, 200)} />
))}
); }; /** * TrendingSection — игры в тренде (быстро растут). */ const TrendingSection = ({ onCardClick }) => { const [items, setItems] = useState([]); useEffect(() => { let active = true; Kubikon3DApi.getTrending(10) .then(r => { if (active) setItems(r.data?.projects || []); }) .catch(() => {}); return () => { active = false; }; }, []); if (!items.length) return null; return (
{items.map((p, i) => (
onCardClick(p)} animateDelay={Math.min(i * 30, 200)} />
))}
); }; /** * CollectionsSection — курируемые подборки от админов. */ const CollectionsSection = ({ onCardClick }) => { const [collections, setCollections] = useState([]); useEffect(() => { let active = true; Kubikon3DApi.getCollections() .then(r => { if (active) setCollections(r.data?.collections || []); }) .catch(() => {}); return () => { active = false; }; }, []); if (!collections.length) return null; return (
{collections.map(col => (
{col.title}
{col.subtitle}
{(col.projects || []).map((p, i) => (
onCardClick(p)} animateDelay={Math.min(i * 30, 200)} />
))}
))}
); }; /** * TopAuthorsSection — рейтинг создателей по сумме плеев. */ const TopAuthorsSection = ({ onUserClick }) => { const [authors, setAuthors] = useState([]); useEffect(() => { let active = true; Kubikon3DApi.getTopAuthors(10) .then(r => { if (active) setAuthors(r.data?.authors || []); }) .catch(() => {}); return () => { active = false; }; }, []); if (!authors.length) return null; return (
{authors.map((a, i) => (
onUserClick(a.user_id)} style={{ background: KT.bgPage, border: `2px solid ${i < 3 ? '#eab30880' : KT.border}`, borderRadius: 14, padding: 14, display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', transition: 'all 240ms cubic-bezier(0.34,1.56,0.64,1)', animation: `kubikonFadeInUp 500ms ease ${i * 50}ms both`, boxShadow: i < 3 ? '0 6px 18px rgba(234, 179, 8, 0.18)' : 'none', }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 14px 32px rgba(234,179,8,0.35)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = i < 3 ? '0 6px 18px rgba(234, 179, 8, 0.18)' : 'none'; }} >
{i === 0 ? : i === 1 ? : i === 2 ? : (i + 1)}
{a.username || ('id ' + a.user_id)}
{a.total_plays.toLocaleString('ru')} · {a.total_games}
))}
); }; /** * ActivityFeedSection — лента «🔴 Live» — последние рекорды. */ const ActivityFeedSection = ({ onProjectClick, onUserClick }) => { const [activity, setActivity] = useState([]); useEffect(() => { let active = true; const load = () => { Kubikon3DApi.getActivity(10) .then(r => { if (active) setActivity(r.data?.activity || []); }) .catch(() => {}); }; load(); // Перезагружать каждые 30 сек const t = setInterval(load, 30000); return () => { active = false; clearInterval(t); }; }, []); if (!activity.length) return null; const formatTime = (ms) => { const sec = ms / 1000; const mm = Math.floor(sec / 60); const s = sec - mm * 60; return mm > 0 ? mm + ':' + s.toFixed(2).padStart(5, '0') : s.toFixed(2); }; return (
LIVE · Свежие рекорды} subtitle="Кто только что побил время" accent="#dc2626" emoji="🔴" />
{activity.map((a, i) => (
onProjectClick(a.project_id)} onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#dc2626'; e.currentTarget.style.transform = 'translateY(-2px)'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = KT.border; e.currentTarget.style.transform = ''; }} > {/* Миниатюра обложки */}
{a.project_thumbnail ? ( ) : (
)}
{ e.stopPropagation(); onUserClick(a.user_id); }} style={{ color: '#3357ff', cursor: 'pointer' }} >{a.username || 'игрок'} {' '}побил рекорд
в «{a.project_title}»
{formatTime(a.time_ms)}
))}
); }; /** * BackgroundParticles — плавающие золотые/синие/розовые частицы по фону страницы. * Чисто декорация, pointer-events: none. Создаются через CSS-анимацию. */ const BackgroundParticles = () => { const particles = React.useMemo(() => { const arr = []; for (let i = 0; i < 18; i++) { arr.push({ id: i, left: Math.random() * 100, top: Math.random() * 100, size: 6 + Math.random() * 14, delay: Math.random() * 8, duration: 6 + Math.random() * 6, iconName: ['sparkles', 'star', 'sparkle', 'gamepad', 'rocket', 'target'][i % 6], }); } return arr; }, []); return (
{particles.map(p => (
))}
); }; /** * LiveStatsBar — горизонтальная полоса с метриками (кол-во игр, авторов, заходов). * Числа анимированно «накручиваются» при появлении. */ const LiveStatsBar = ({ totalGames = 0 }) => { const stats = [ { icon: 'gamepad', label: 'Игр в каталоге', value: totalGames, color: '#3357ff' }, { icon: 'trophy', label: 'Рекордов установлено', value: 248, color: '#ffd700' }, { icon: 'users', label: 'Создателей', value: 67, color: '#22d97a' }, { icon: 'zap', label: 'Сейчас онлайн', value: 'live', color: '#ec4899' }, ]; return (
{stats.map((s, i) => ( ))}
); }; const StatCard = ({ icon, label, value, color, delay }) => { const [hovered, setHovered] = useState(false); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ position: 'relative', background: KT.bgPage, border: `2px solid ${color}30`, borderRadius: 16, padding: '16px 20px', overflow: 'hidden', boxShadow: hovered ? `0 18px 40px ${color}40` : `0 4px 14px ${color}18`, transform: hovered ? 'translateY(-4px) scale(1.02)' : 'translateY(0)', transition: 'all 280ms cubic-bezier(0.34, 1.56, 0.64, 1)', cursor: 'default', animation: `kubikonFadeInUp 600ms cubic-bezier(0.34, 1.56, 0.64, 1) ${delay}ms both`, }} > {/* Декор-кружок в углу */}
{typeof value === 'number' ? : ● LIVE}
{label}
); }; /** Анимация числа от 0 до to за 1.2с. */ const CountUpNumber = ({ to }) => { const [val, setVal] = useState(0); useEffect(() => { let raf; const start = performance.now(); const dur = 1200; const tick = (t) => { const k = Math.min(1, (t - start) / dur); // easeOutCubic const e = 1 - Math.pow(1 - k, 3); setVal(Math.round(to * e)); if (k < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [to]); return <>{val.toLocaleString('ru')}; }; export default KubikonFeed;