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)',
}}
>
setShowRegModal(false)}
style={{
position: 'absolute', top: 12, right: 12,
width: 36, height: 36, borderRadius: 18,
border: 'none',
background: '#F3E8FF', color: '#1E1B4B',
fontSize: 20, fontWeight: 800,
cursor: 'pointer',
}}
aria-label="Закрыть"
>
)}
{/* === 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 дней */}
requireAuth(() => navigate(`/game/${p.id}`))}
/>
{/* Топ-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 => (
setSort(s.id)}
style={chipStyle(sort === s.id)}
>
{s.icon && }
{s.label}
))}
{
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 (см. задачу выше). */}
{/* Пояснение про браузер — мелкое серое */}
Почему? {' '}
В браузере мы упёрлись в потолок: ограничения WebGL по графике,
провалы FPS на сложных сценах, частые проблемы со звуком и
вводом. В нативном приложении всё это работает стабильно,
быстро и красиво — как и должно быть.
{/* Модалка-уведомление «скоро появится» (Windows / Android / iOS) */}
{comingSoon && (
setComingSoon(null)}
/>
)}
);
};
/**
* Универсальная кнопка-заглушка «Скоро для <платформы>».
* Используется на 3 кнопках Windows / Android / iOS в DownloadAppsBanner.
*/
const ComingSoonPlatformButton = ({ compact, iconName, iconEmoji, iconBg, label, sub, onClick }) => (
{
e.currentTarget.style.background = '#f1f5f9';
e.currentTarget.style.borderColor = KT.accent;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.borderColor = KT.borderStrong;
}}
>
{iconEmoji
?
: }
СКОРО
);
/** Модалка «<Платформа> скоро появится». Универсальная — параметр
* `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 ? (
) : (
)}
{/* Gradient overlay на hover */}
{/* HOT badge */}
{isHot && (
HOT #{rank + 1}
)}
{/* NEW badge — сверху-слева. Бейдж STAFF PICK убран —
рангов в умной ленте больше нет. */}
{isNew && (
NEW
)}
{/* Кнопка избранного — сверху справа, рядом с age badge.
Используем SVG-иконку чтобы сердечко было симметричным
(эмоджи 🤍/❤️ кривые в большинстве шрифтов). */}
{showFavorite && userIdMemo && (
{
e.currentTarget.style.transform = 'scale(1.18)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
)}
{/* 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 */}
setOpen(o => !o)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '8px 14px',
background: open ? KT.bgPage : (hovered ? KT.bgPage : KT.bgPage),
border: `1px solid ${open || hovered ? KT.accent : KT.border}`,
borderRadius: 999,
cursor: 'pointer',
fontFamily: KT.font,
transition: 'all 200ms ease',
boxShadow: open
? KT.shadowGlow
: (hovered ? KT.shadowSm : 'none'),
}}
>
{label}
▾
{/* Dropdown menu */}
{open && (
{options.map(o => (
{
onChange(o.value);
setOpen(false);
}}
/>
))}
)}
);
};
const FilterOption = ({ option, selected, onClick }) => {
const [hovered, setHovered] = useState(false);
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
width: '100%', boxSizing: 'border-box',
padding: '10px 12px',
background: selected
? KT.gradientBrand
: (hovered ? KT.bgHover : 'transparent'),
color: selected ? '#fff' : KT.text,
border: 'none',
borderRadius: KT.radius,
cursor: 'pointer',
fontSize: 14, fontWeight: selected ? 700 : 600,
fontFamily: KT.font,
textAlign: 'left',
transition: 'all 150ms ease',
boxShadow: selected ? '0 4px 12px rgba(51,87,255,0.28)' : 'none',
}}
>
{option.label}
{selected && (
)}
);
};
// ================================================================
// === 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}
)}
Дата рождения хранится только для возрастной фильтрации.
Обработка регулируется{' '}
Политикой обработки ПД
, на которую ты дал согласие при регистрации.
setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
...buttonPrimary(hovered && canSubmit),
width: '100%', padding: '14px 24px', fontSize: 15,
opacity: canSubmit ? 1 : 0.5,
cursor: canSubmit ? 'pointer' : 'not-allowed',
animation: canSubmit
? 'kubikonPulseGlow 2.4s ease-in-out infinite'
: 'none',
}}
>
{submitting ? 'Сохранение…' : <> Поехали!>}
);
};
const DobSelect = ({ label, value, onChange, options, flex }) => (
{label}
onChange(e.target.value)}
style={{
width: '100%',
background: KT.bgPage,
color: value ? KT.text : KT.textMuted,
border: `1px solid ${value ? KT.accent : KT.border}`,
borderRadius: KT.radius,
padding: '10px 8px',
fontSize: 14, fontWeight: 600,
fontFamily: KT.font,
cursor: 'pointer',
outline: 'none',
transition: 'border-color 150ms ease',
}}
>
—
{options.map(o => (
{o.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) => (
onScrollTo(c.id)}
style={{
flex: '0 0 auto',
padding: '10px 20px',
background: KT.bgPage,
border: `2px solid ${c.color}40`,
borderRadius: 999,
color: KT.text,
fontWeight: 700, fontSize: 14,
cursor: 'pointer',
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
whiteSpace: 'nowrap',
animation: `kubikonFadeInUp 500ms cubic-bezier(0.34, 1.56, 0.64, 1) ${i * 60}ms both`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = c.color;
e.currentTarget.style.color = '#fff';
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
e.currentTarget.style.boxShadow = `0 8px 22px ${c.color}66`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = KT.bgPage;
e.currentTarget.style.color = KT.text;
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>{c.label}
))}
);
};
/**
* 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.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.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 (
{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 ? (
) : (
)}
);
};
/**
* 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 && (
scrollBy(-1)}
style={{
position: 'absolute',
left: -8, top: '50%',
transform: `translate(0, -50%) scale(${hovered ? 1 : 0.92})`,
width: 44, height: 44, borderRadius: '50%',
background: `linear-gradient(135deg, ${accent}, ${accent}cc)`,
border: 'none',
color: '#fff',
fontSize: 22, fontWeight: 900,
cursor: 'pointer',
boxShadow: `0 8px 22px ${accent}55`,
opacity: hovered ? 1 : 0.55,
transition: 'all 220ms cubic-bezier(0.34,1.56,0.64,1)',
zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translate(0, -50%) scale(1.1)';
e.currentTarget.style.opacity = '1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = `translate(0, -50%) scale(${hovered ? 1 : 0.92})`;
e.currentTarget.style.opacity = hovered ? '1' : '0.55';
}}
>‹
)}
{/* Кнопка вправо */}
{canRight && (
scrollBy(1)}
style={{
position: 'absolute',
right: -8, top: '50%',
transform: `translate(0, -50%) scale(${hovered ? 1 : 0.92})`,
width: 44, height: 44, borderRadius: '50%',
background: `linear-gradient(135deg, ${accent}, ${accent}cc)`,
border: 'none',
color: '#fff',
fontSize: 22, fontWeight: 900,
cursor: 'pointer',
boxShadow: `0 8px 22px ${accent}55`,
opacity: hovered ? 1 : 0.55,
transition: 'all 220ms cubic-bezier(0.34,1.56,0.64,1)',
zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translate(0, -50%) scale(1.1)';
e.currentTarget.style.opacity = '1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = `translate(0, -50%) scale(${hovered ? 1 : 0.92})`;
e.currentTarget.style.opacity = hovered ? '1' : '0.55';
}}
>›
)}
);
};
/**
* 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.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;