studio/src/community/KubikonFeed.jsx
МИН 9b97f7adba
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 1m55s
CI / Secret scan (pull_request) Successful in 2m32s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
ci(lint): понизить стилевые правила до warn/off + чинить no-undef STORYS_addres
CI-задача Lint падала на 864 legacy-ошибках (no-empty 538, no-unescaped-entities 322)
во всём проекте — это осознанный код-стиль, не баги. Понизил их до off.
Единичные no-useless-catch/no-constant-condition/no-fallthrough → warn.
Реальный баг: KubikonFeed.jsx использовал STORYS_addres без импорта — добавил импорт.

Теперь npm run lint = exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:40:08 +03:00

3768 lines
163 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div style={{
minHeight: '100vh',
background: KT.bg,
color: KT.text,
fontFamily: KT.font,
}}>
<style>{KUBIKON_KEYFRAMES}</style>
{/* === Модалка для гостя — войди или зарегистрируйся === */}
{showRegModal && (
<div
onClick={() => 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',
}}
>
<div
onClick={(e) => 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)',
}}
>
<button
onClick={() => 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="Закрыть"
><Icon name="close" size={14} /></button>
<PleeseReg textDefault='Чтобы играть и создавать игры в Рублоксе' style={{width:'auto',height:'auto'}} />
</div>
</div>
)}
{/* === Sticky header (glass) === */}
<div style={{
background: KT.glassDark,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${KT.borderSoft}`,
padding: '14px 20px',
position: 'sticky', top: 0, zIndex: 50,
}}>
<div style={{
maxWidth: 1240, margin: '0 auto',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
{/* Возврат в школу — только на мобиле/планшете, где
меню школы скрыто. На десктопе слева есть левое меню
сайта, отдельная кнопка не нужна. */}
{isMobileLayout && (
<Link to="/" style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
color: KT.textSecondary,
textDecoration: 'none',
fontSize: 13, fontWeight: 600,
whiteSpace: 'nowrap',
}} title="Вернуться в Майнкрафтию">
<Icon name="arrow-left" size={14} /> Майнкрафтия
</Link>
)}
<Link to="/kubikon" style={{
display: 'flex', alignItems: 'center', gap: 10,
textDecoration: 'none',
}}>
<RublocsLogo size={38} />
<span style={{
fontSize: 22, fontWeight: 800, color: KT.text,
letterSpacing: -0.3,
}}>Рублокс</span>
</Link>
<Link to="/" style={{
padding: '8px 16px',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
color: KT.textSecondary,
textDecoration: 'none',
fontSize: 13, fontWeight: 600,
}}><Icon name="folder-open" size={13} /> Мои игры</Link>
<SearchInput value={searchQ} onChange={setSearchQ} />
</div>
</div>
{/* === HERO BANNER === */}
{!searchResults && !searchQ && (
<HeroBanner totalGames={totalCount || items.length} onCreate={() => requireAuth(() => navigate('/'))} />
)}
{/* === СКАЧАТЬ ПРИЛОЖЕНИЕ === */}
{!searchResults && !searchQ && (
<DownloadAppsBanner />
)}
{/* === ТЕМАТИЧЕСКИЕ СЕКЦИИ — РАДИКАЛЬНЫЙ РЕДИЗАЙН === */}
{!searchResults && !searchQ && profileLoaded && (
<div style={{
maxWidth: 1340, margin: '0 auto', padding: '8px 24px 0',
position: 'relative',
}}>
{/* Анимированные золотые частицы фона */}
<BackgroundParticles />
{/* Активные события платформы */}
<EventBanner onCardClick={(url) => navigate(url)} />
{/* Live-statistics — пульсирующая полоса метрик */}
<LiveStatsBar totalGames={totalCount || items.length} />
{/* Чипы-теги быстрого перехода */}
<CategoryChips onScrollTo={(id) => {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}} />
{/* «Продолжить играть» — последняя сыгранная */}
{userId && (
<ResumeCard userId={userId}
onClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))} />
)}
{/* История — недавно играли */}
{userId && (
<div id="sec-history">
<HistorySection userId={userId}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))} />
</div>
)}
{/* Избранное — мои сохранённые */}
{userId && (
<div id="sec-fav">
<FavoritesSection userId={userId}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))} />
</div>
)}
{/* Спотлайт «Игра дня» — гигантский баннер */}
<FeaturedBanner
params={{ sort: 'top_week', maxAge, per_page: 1 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
{/* Тренды — резкий рост за 14 дней */}
<div id="sec-trending">
<TrendingSection
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Топ-3 на пьедестале */}
<div id="sec-top">
<PodiumSection
title="Топ-3 на пьедестале"
subtitle="Самые любимые игры всех времён"
params={{ sort: 'popular', maxAge, per_page: 3 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Огненная неделя */}
<div id="sec-fire">
<CategorySection
layout="carousel"
title="Топ за неделю"
subtitle="Что сейчас на хайпе"
accent="#ff5e3a"
emoji="🔥"
params={{ sort: 'top_week', maxAge, per_page: 10 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Блок «Лучшие игры» — топ по рейтингу умной ленты */}
<div id="sec-premium">
<PremiumSection
params={{ maxAge, per_page: 6 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Мультиплеер — баннер-разделитель + 3 крупные */}
<SectionBanner
emoji="🎮"
title="Зови друзей в комнату!"
subtitle="Мультиплеер — играй вместе"
gradient="linear-gradient(135deg, #5b8bff 0%, #3357ff 50%, #1e2da5 100%)"
/>
<div id="sec-mp">
<CategorySection
layout="spotlight"
title=""
subtitle=""
accent="#5b8bff"
emoji="🎮"
hideHeader={true}
params={{ multiplayer: true, sort: 'popular', maxAge, per_page: 6 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Новинки */}
<div id="sec-new">
<CategorySection
layout="carousel"
title="Новинки"
subtitle="Только что опубликованы — оцените первыми"
accent="#22d97a"
emoji="🆕"
params={{ sort: 'new', maxAge, per_page: 10 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Жанры — баннер-разделитель */}
<SectionBanner
emoji="🎨"
title="Что хочешь сегодня?"
subtitle="Выбери любимый жанр"
gradient="linear-gradient(135deg, #ec4899 0%, #9966ff 50%, #3b82f6 100%)"
/>
{/* Жанры в 2 колонки на десктопе, 1 на мобилке */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: 24, marginTop: 24,
}}>
<div id="sec-platformer">
<CompactGenreCard
title="Платформеры"
subtitle="Прыгай, беги, не падай"
accent="#9966ff"
bgGradient="linear-gradient(135deg, rgba(153,102,255,0.12), rgba(153,102,255,0.02))"
params={{ genre: 'platformer', sort: 'popular', maxAge, per_page: 4 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
<div id="sec-puzzle">
<CompactGenreCard
title="Головоломки"
subtitle="Включи мозг"
accent="#3b82f6"
bgGradient="linear-gradient(135deg, rgba(59,130,246,0.12), rgba(59,130,246,0.02))"
params={{ genre: 'puzzle', sort: 'popular', maxAge, per_page: 4 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
<div id="sec-action">
<CompactGenreCard
title="Экшен"
subtitle="Стрельба, бои, адреналин"
accent="#ef4444"
bgGradient="linear-gradient(135deg, rgba(239,68,68,0.12), rgba(239,68,68,0.02))"
params={{ genre: 'action', sort: 'popular', maxAge, per_page: 4 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
<div id="sec-other">
<CompactGenreCard
title="Разное"
subtitle="Эксперименты и веселье"
accent="#14b8a6"
bgGradient="linear-gradient(135deg, rgba(20,184,166,0.12), rgba(20,184,166,0.02))"
params={{ genre: 'other', sort: 'popular', maxAge, per_page: 4 }}
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
</div>
{/* Коллекции от админов */}
<div id="sec-collections">
<CollectionsSection
onCardClick={(p) => requireAuth(() => navigate(`/game/${p.id}`))}
/>
</div>
{/* Топ-авторы недели */}
<div id="sec-authors">
<TopAuthorsSection
onUserClick={(uid) => navigate(`/profile/${uid}`)}
/>
</div>
{/* Лента активности (последние рекорды) */}
<div id="sec-activity">
<ActivityFeedSection
onProjectClick={(pid) => requireAuth(() => navigate(`/game/${pid}`))}
onUserClick={(uid) => navigate(`/profile/${uid}`)}
/>
</div>
{/* CTA-баннер «Создай свою!» */}
<CreateBanner onClick={() => requireAuth(() => navigate('/'))} />
</div>
)}
<div style={{ maxWidth: 1240, margin: '0 auto', padding: '20px 24px 64px' }}>
{/* Заголовок «Все игры» — отделяет нижнюю секцию ленты от категорий */}
{!searchResults && !searchQ && profileLoaded && (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
margin: '24px 0 12px',
}}>
<h2 style={{
fontSize: 26, fontWeight: 900, color: KT.text, margin: 0,
letterSpacing: -0.5,
}}><Icon name="library" size={13} /> Все игры</h2>
<div style={{ flex: 1, height: 1, background: KT.borderSoft }} />
</div>
)}
{/* === Фильтры === */}
{!searchResults && (
<div style={{
display: 'flex', gap: 10, marginBottom: 28,
alignItems: 'center', flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{SORTS.map(s => (
<button
key={s.id}
onClick={() => setSort(s.id)}
style={chipStyle(sort === s.id)}
>
{s.icon && <Icon emoji={s.icon} size={13} style={{ marginRight: 4, verticalAlign: '-2px' }} />}
{s.label}
</button>
))}
</div>
<div style={{
display: 'flex', gap: 8,
marginLeft: 'auto', alignItems: 'center', flexWrap: 'wrap',
}}>
<FilterSelect
label={ageMode === 'auto' && userAge != null
? `Возраст: до ${userAgeToMaxAge(userAge)}+`
: 'Возраст'}
value={String(ageMode)}
onChange={(v) => {
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,
}))}
/>
<FilterSelect
label={minRating > 0 ? `Рейтинг: ≥ ${minRating}%` : 'Рейтинг'}
value={String(minRating)}
onChange={(v) => setMinRating(Number(v) || 0)}
options={RATING_FILTERS.map(r => ({
value: String(r.id), label: r.label,
}))}
/>
</div>
</div>
)}
{searchResults && (
<div style={{
fontSize: 14, color: KT.textSecondary, marginBottom: 20,
marginTop: 24,
}}>
Найдено: <b style={{ color: KT.text }}>{searchResults.length}</b>
</div>
)}
{/* Section title — зависит от выбранной вкладки ленты */}
{!searchResults && !searchQ && !loading && list.length > 0 && (
<h2 style={{
fontSize: 22, fontWeight: 800, color: KT.text,
margin: '0 0 6px',
letterSpacing: -0.3,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{sort === 'recommended' ? 'Рекомендуем тебе'
: sort === 'new' ? 'Новые игры'
: sort === 'top_week' ? 'Топ за неделю'
: 'Популярное сейчас'}
</h2>
)}
{/* Подпись-объяснение под заголовком — простыми словами для детей */}
{!searchResults && !searchQ && !loading && list.length > 0 && (
<p style={{
fontSize: 13, color: KT.textSecondary,
margin: '0 0 18px', lineHeight: 1.5,
}}>
{sort === 'recommended'
? 'Игры, которые сейчас больше всего нравятся игрокам — лайки, просмотры и время в игре.'
: sort === 'new'
? 'Самые свежие игры — только что опубликованы. Загляни первым!'
: sort === 'top_week'
? 'Игры этой недели, в которые играют чаще всего.'
: 'Игры с наибольшим числом запусков за всё время.'}
</p>
)}
{loading ? (
<SkeletonGrid />
) : list.length === 0 ? (
<EmptyState searchMode={!!searchResults} />
) : (
<>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 18,
}}>
{list.map((p, i) => (
<GameCard
key={p.id}
project={p}
rank={i}
sort={sort}
onClick={() => requireAuth(() => navigate(`/game/${p.id}`))}
animateDelay={Math.min(i * 35, 600)}
/>
))}
</div>
{/* Sentinel + индикатор подгрузки (только в режиме ленты, не в поиске) */}
{!searchResults && hasMore && (
<div ref={sentinelRef} style={{
marginTop: 30, padding: 24,
textAlign: 'center', color: KT.textSecondary,
fontSize: 14,
}}>
{loadingMore ? <><Icon name="hourglass" size={13} /> Загружаю ещё...</> : ' '}
</div>
)}
{!searchResults && !hasMore && items.length > PER_PAGE && (
<div style={{
marginTop: 30, padding: 16,
textAlign: 'center', color: KT.textSecondary,
fontSize: 13, opacity: 0.6,
}}>
Это все игры {items.length} {pluralRu(items.length, ['игра', 'игры', 'игр'])}
</div>
)}
</>
)}
</div>
{askDob && (
<BirthDateModal
onSave={async (dob) => {
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; }
}}
/>
)}
</div>
);
};
// ================================================================
// === HERO BANNER — вверху ленты, с градиентом и плавающими кубиками ===
// ================================================================
const HeroBanner = ({ totalGames, onCreate }) => {
const { isPhone, isTablet } = useDeviceType();
const compact = isPhone || isTablet;
return (
<div style={{
position: 'relative',
margin: compact ? '12px auto' : '20px auto',
maxWidth: 1240, padding: compact ? '0 12px' : '0 24px',
}}>
<div style={{
position: 'relative',
background: KT.gradientHero,
backgroundSize: '200% 200%',
borderRadius: KT.radius2xl,
padding: compact ? '24px 20px' : '48px 40px',
overflow: 'hidden',
boxShadow: '0 24px 48px rgba(51, 87, 255, 0.18)',
animation: 'kubikonHeroSlide 600ms cubic-bezier(0.34, 1.56, 0.64, 1), '
+ 'kubikonGradientShift 12s ease-in-out infinite',
}}>
{/* Плавающие кубики (декор) — только на десктопе, на мобиле
занимают место и сбивают компоновку */}
{!compact && <FloatingShapes />}
<div style={{
position: 'relative', zIndex: 2,
display: 'flex', alignItems: 'center', gap: compact ? 16 : 32,
flexWrap: 'wrap',
}}>
<div style={{ flex: 1, minWidth: compact ? 0 : 280 }}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'rgba(255, 255, 255, 0.18)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255, 255, 255, 0.25)',
padding: '6px 14px', borderRadius: 999,
fontSize: 12, fontWeight: 700, color: '#fff',
marginBottom: compact ? 10 : 16, letterSpacing: 1,
textTransform: 'uppercase',
}}>
<span style={{ animation: 'kubikonSpin 4s linear infinite',
display: 'inline-block' }}><Icon name="zap" size={14} /></span>
Новая платформа
</div>
<h1 style={{
margin: 0,
fontSize: compact ? 24 : 42,
lineHeight: 1.1,
fontWeight: 900, color: '#fff',
letterSpacing: -1,
textShadow: '0 2px 12px rgba(0, 0, 0, 0.12)',
}}>
Создавай игры.<br />
Играй с друзьями.
</h1>
<p style={{
marginTop: compact ? 8 : 12, marginBottom: 0,
fontSize: compact ? 13 : 16,
color: 'rgba(255, 255, 255, 0.92)',
maxWidth: 480, lineHeight: 1.5,
}}>
Рублокс твоя площадка для 3D-игр.
Лучший опыт в нативном приложении на ПК или телефоне.
</p>
<div style={{
display: 'flex',
gap: compact ? 8 : 12,
marginTop: compact ? 14 : 24,
flexWrap: 'wrap',
}}>
<button onClick={onCreate} style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: compact ? '10px 16px' : '14px 24px',
background: '#fff',
color: KT.accent,
border: 'none',
cursor: 'pointer',
borderRadius: KT.radius,
fontSize: compact ? 13 : 15, fontWeight: 800,
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.16)',
letterSpacing: 0.2,
}}>
<Icon emoji="🛠️" size={15} /> Создать игру
</button>
{totalGames > 0 && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: compact ? '10px 14px' : '14px 20px',
background: 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255, 255, 255, 0.22)',
borderRadius: KT.radius,
color: '#fff',
fontSize: compact ? 12 : 14, fontWeight: 700,
}}><Icon name="gamepad" size={14} /><b>{totalGames}</b> {pluralRu(totalGames, ['игра', 'игры', 'игр'])} прямо сейчас
</div>
)}
</div>
</div>
{/* Большой логотип справа (плавает) — только на десктопе.
На мобиле скрыт, чтобы не съедал ширину. */}
{!compact && (
<div style={{
width: 180, height: 180, flexShrink: 0,
animation: 'kubikonFloat 6s ease-in-out infinite',
filter: 'drop-shadow(0 16px 32px rgba(0, 0, 0, 0.20))',
}}>
<RublocsLogo size={180} bg="#ffffff" />
</div>
)}
</div>
</div>
</div>
);
};
// ================================================================
// === 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 (
<div style={{
maxWidth: 1240,
margin: compact ? '4px auto 16px' : '12px auto 24px',
padding: compact ? '0 12px' : '0 24px',
}}>
<div style={{
background: '#fff',
border: `1px solid ${KT.border}`,
borderRadius: KT.radius2xl,
padding: compact ? '20px 18px' : '32px 36px',
boxShadow: '0 12px 32px rgba(15, 23, 42, 0.06)',
position: 'relative',
overflow: 'hidden',
}}>
{/* Декоративные акценты в углах */}
<div style={{
position: 'absolute', top: -40, right: -40,
width: 180, height: 180, borderRadius: '50%',
background: `radial-gradient(circle, ${KT.accent}18 0%, transparent 70%)`,
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', bottom: -60, left: -60,
width: 220, height: 220, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.12) 0%, transparent 70%)',
pointerEvents: 'none',
}} />
<div style={{ position: 'relative', zIndex: 2 }}>
{/* Бейдж важности */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 12px',
background: 'rgba(245, 158, 11, 0.15)',
border: '1px solid rgba(245, 158, 11, 0.35)',
color: '#92400e',
borderRadius: 999,
fontSize: 11, fontWeight: 800,
letterSpacing: 1, textTransform: 'uppercase',
marginBottom: 12,
}}>
<Icon emoji="⚡" size={13} /> Важно
</div>
<h2 style={{
margin: 0,
fontSize: compact ? 22 : 30,
fontWeight: 900,
color: KT.text,
letterSpacing: -0.5,
lineHeight: 1.15,
}}>
Приложение Рублокса скоро
</h2>
<p style={{
margin: '10px 0 0',
fontSize: compact ? 13 : 15,
color: KT.textSecondary,
lineHeight: 1.55,
maxWidth: 720,
}}>
В браузере не удалось добиться нужного уровня графики, FPS и
стабильности поэтому в ближайшие месяцы веб-версия игры и
редактор будут отключены. Полностью переходим на нативные
приложения. Приложения для Windows, Android и iOS в разработке,
скоро появятся. Пока что играй в браузере.
</p>
{/* Три большие кнопки: Windows / Android / iOS.
Все три — пока «Скоро появится». Windows-сборку
отложили 2026-05-24 (см. задачу выше). */}
<div style={{
display: 'grid',
gridTemplateColumns: compact ? '1fr' : '1fr 1fr 1fr',
gap: compact ? 12 : 16,
marginTop: compact ? 18 : 24,
}}>
{/* === Windows (заглушка «Скоро») === */}
<ComingSoonPlatformButton
compact={compact}
iconName="monitor"
iconEmoji="💻"
iconBg="#3357ff22"
label="Windows"
sub="в разработке · скоро"
onClick={() => setComingSoon('windows')}
/>
{/* === Android === */}
{androidAvailable ? (
// Активная кнопка — прямая загрузка APK.
// Имя файла с версией → браузер не сохранит старую версию из кэша.
<a
href={android.apk_url}
download
style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: compact ? '14px 16px' : '20px 24px',
background: '#10b981',
color: '#fff',
borderRadius: KT.radiusLg,
textDecoration: 'none',
boxShadow: '0 12px 28px rgba(16, 185, 129, 0.32)',
transition: 'transform 200ms ease, box-shadow 200ms ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
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)';
}}
>
<div style={{
width: compact ? 42 : 52, height: compact ? 42 : 52,
background: 'rgba(255, 255, 255, 0.18)',
borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: compact ? 22 : 28, flexShrink: 0,
}}><Icon name="smartphone" size={13} /> </div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 11, fontWeight: 700,
opacity: 0.85, letterSpacing: 1,
textTransform: 'uppercase',
}}>
Скачать для
</div>
<div style={{
fontSize: compact ? 17 : 22, fontWeight: 900,
marginTop: 2,
}}>
Android
</div>
<div style={{
fontSize: 12, opacity: 0.8, marginTop: 2,
}}>
{`.apk · ${android.download_size_mb} МБ · v${android.version}`}
</div>
</div>
<div style={{ fontSize: 22, opacity: 0.9 }}><Icon name="arrow-down" size={14} /></div>
</a>
) : (
<ComingSoonPlatformButton
compact={compact}
iconName="smartphone"
iconBg="#10b98122"
label="Android"
sub="в разработке · скоро в Google Play"
onClick={() => setComingSoon('android')}
/>
)}
{/* === iOS (всегда «Скоро») === */}
<ComingSoonPlatformButton
compact={compact}
iconName="smartphone"
iconEmoji="🍎"
iconBg="#f1f5f9"
label="iOS"
sub="iPhone / iPad · скоро в App Store"
onClick={() => setComingSoon('ios')}
/>
</div>
{/* Пояснение про браузер — мелкое серое */}
<div style={{
marginTop: compact ? 14 : 18,
padding: compact ? '10px 12px' : '12px 16px',
background: '#f8fafc',
border: `1px solid ${KT.border}`,
borderRadius: KT.radius,
fontSize: 12,
color: KT.textMuted,
lineHeight: 1.5,
}}>
<b style={{ color: KT.text }}>Почему?</b>{' '}
В браузере мы упёрлись в потолок: ограничения WebGL по графике,
провалы FPS на сложных сценах, частые проблемы со звуком и
вводом. В нативном приложении всё это работает стабильно,
быстро и красиво как и должно быть.
</div>
</div>
</div>
{/* Модалка-уведомление «скоро появится» (Windows / Android / iOS) */}
{comingSoon && (
<PlatformComingSoonModal
platform={comingSoon}
onClose={() => setComingSoon(null)}
/>
)}
</div>
);
};
/**
* Универсальная кнопка-заглушка «Скоро для <платформы>».
* Используется на 3 кнопках Windows / Android / iOS в DownloadAppsBanner.
*/
const ComingSoonPlatformButton = ({ compact, iconName, iconEmoji, iconBg, label, sub, onClick }) => (
<button
type="button"
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: compact ? '14px 16px' : '20px 24px',
background: '#f8fafc',
color: KT.text,
border: `1px dashed ${KT.borderStrong}`,
borderRadius: KT.radiusLg,
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'background 200ms ease, border-color 200ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
e.currentTarget.style.borderColor = KT.accent;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.borderColor = KT.borderStrong;
}}
>
<div style={{
width: compact ? 42 : 52, height: compact ? 42 : 52,
background: iconBg,
borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: compact ? 22 : 28, flexShrink: 0,
}}>
{iconEmoji
? <Icon emoji={iconEmoji} size={compact ? 22 : 28} />
: <Icon name={iconName || 'smartphone'} size={compact ? 18 : 22} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 11, fontWeight: 700,
color: KT.textMuted, letterSpacing: 1,
textTransform: 'uppercase',
}}>
Скоро для
</div>
<div style={{
fontSize: compact ? 17 : 22, fontWeight: 900,
marginTop: 2, color: KT.text,
}}>
{label}
</div>
<div style={{
fontSize: 12, color: KT.textMuted, marginTop: 2,
}}>
{sub}
</div>
</div>
<div style={{
padding: '4px 10px',
background: '#10b98122', color: '#065f46',
borderRadius: 999,
fontSize: 11, fontWeight: 800,
letterSpacing: 0.5,
}}>
СКОРО
</div>
</button>
);
/** Модалка «<Платформа> скоро появится». Универсальная — параметр
* `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 (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16,
animation: 'kubikonFadeIn 200ms ease',
}}
>
<div
onClick={(e) => 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',
}}
>
<button
onClick={onClose}
aria-label="Закрыть"
style={{
position: 'absolute', top: 14, right: 14,
background: 'transparent', border: 'none', cursor: 'pointer',
fontSize: 24, color: KT.textMuted, lineHeight: 1,
}}
>×</button>
<div style={{
width: 64, height: 64, margin: '0 auto 8px',
borderRadius: '50%', background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}><Icon name={info.icon} size={28} /></div>
<h3 style={{
margin: '8px 0 6px',
fontSize: 22, fontWeight: 900, color: KT.text,
}}>
{info.title}
</h3>
<p style={{
margin: '0 0 20px',
fontSize: 14, color: KT.textSecondary,
lineHeight: 1.55,
}}>
{info.text}
</p>
<button
onClick={onClose}
style={{
...buttonPrimary,
padding: '12px 28px',
fontSize: 14,
}}
>
Ок, понял!
</button>
</div>
</div>
);
};
/** Плавающие декоративные shapes на фоне hero. */
const FloatingShapes = () => (
<>
<div style={{
position: 'absolute', top: '15%', left: '8%',
width: 80, height: 80,
background: 'rgba(255, 255, 255, 0.12)',
borderRadius: 16,
transform: 'rotate(15deg)',
animation: 'kubikonFloat 8s ease-in-out infinite',
}} />
<div style={{
position: 'absolute', top: '60%', left: '4%',
width: 56, height: 56,
background: 'rgba(255, 255, 255, 0.10)',
borderRadius: 14,
transform: 'rotate(-10deg)',
animation: 'kubikonFloat 10s ease-in-out 1s infinite',
}} />
<div style={{
position: 'absolute', top: '25%', right: '32%',
width: 40, height: 40,
background: 'rgba(255, 255, 255, 0.14)',
borderRadius: 10,
transform: 'rotate(28deg)',
animation: 'kubikonFloat 7s ease-in-out 2s infinite',
}} />
<div style={{
position: 'absolute', bottom: '12%', right: '18%',
width: 64, height: 64,
background: 'rgba(255, 255, 255, 0.10)',
borderRadius: 16,
transform: 'rotate(-18deg)',
animation: 'kubikonFloat 9s ease-in-out 0.5s infinite',
}} />
</>
);
// ================================================================
// === 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 (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { 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 */}
<div style={{
aspectRatio: '4/3',
background: '#e2e8f0',
position: 'relative', overflow: 'hidden',
}}>
{p.thumbnail ? (
<img
src={p.thumbnail}
alt={p.title}
style={{
width: '100%', height: '100%',
objectFit: 'cover',
transform: hovered ? 'scale(1.10)' : 'scale(1)',
transition: 'transform 700ms cubic-bezier(0.2, 0.8, 0.4, 1)',
}}
/>
) : (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 64, color: KT.textMuted,
}}><Icon name="gamepad" size={14} /></div>
)}
{/* Gradient overlay на hover */}
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, transparent 50%, rgba(15, 23, 42, 0.6) 100%)',
opacity: hovered ? 1 : 0.7,
transition: 'opacity 300ms ease',
pointerEvents: 'none',
}} />
{/* HOT badge */}
{isHot && (
<div style={{
position: 'absolute', top: 10, left: 10,
background: KT.gradientHot,
color: '#fff',
padding: '4px 10px',
borderRadius: 999,
fontSize: 11, fontWeight: 800,
letterSpacing: 0.5,
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.40)',
animation: 'kubikonHotPulse 1.4s ease-in-out infinite',
}}>
<Icon name="flame" size={13} /> HOT #{rank + 1}
</div>
)}
{/* NEW badge — сверху-слева. Бейдж STAFF PICK убран —
рангов в умной ленте больше нет. */}
<div style={{
position: 'absolute', top: 10, left: 10,
display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 2,
}}>
{isNew && (
<span style={{
background: 'linear-gradient(135deg, #22d97a, #14b8a6)',
color: '#fff', fontSize: 10, padding: '3px 8px',
borderRadius: 999, fontWeight: 900, letterSpacing: 0.5,
boxShadow: '0 4px 12px rgba(34, 217, 122, 0.4)',
animation: 'kubikonGlow 2s ease-in-out infinite',
}}><Icon name="sparkles" size={13} /> NEW</span>
)}
</div>
{/* Кнопка избранного — сверху справа, рядом с age badge.
Используем SVG-иконку чтобы сердечко было симметричным
(эмоджи 🤍/❤️ кривые в большинстве шрифтов). */}
{showFavorite && userIdMemo && (
<button
onClick={handleFav}
title={favorited ? 'Убрать из избранного' : 'В избранное'}
style={{
position: 'absolute', top: 8, right: 56,
width: 32, height: 32, borderRadius: '50%',
background: favorited
? 'linear-gradient(135deg, #ef4444, #ec4899)'
: 'rgba(15, 23, 42, 0.65)',
border: 'none',
color: '#fff',
cursor: 'pointer',
zIndex: 3,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0,
boxShadow: favorited
? '0 4px 14px rgba(239, 68, 68, 0.55)'
: '0 2px 6px rgba(0,0,0,0.25)',
backdropFilter: 'blur(8px)',
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.18)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<svg
width="16" height="16" viewBox="0 0 24 24"
fill={favorited ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={favorited ? 0 : 2.2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
)}
{/* Age badge */}
<span style={{
position: 'absolute', top: 10, right: 10,
background: 'rgba(15, 23, 42, 0.85)',
color: '#fff',
fontSize: 11, padding: '3px 9px',
borderRadius: 999, fontWeight: 700,
backdropFilter: 'blur(8px)',
}}>{p.age_rating || 12}+</span>
{/* Stats overlay снизу */}
<div style={{
position: 'absolute', bottom: 10, left: 10, right: 10,
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 12, fontWeight: 700,
pointerEvents: 'none',
}}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'rgba(255, 255, 255, 0.92)',
color: ratingColor(p.rating_percent),
padding: '3px 9px', borderRadius: 999,
backdropFilter: 'blur(8px)',
}}>
<Icon name="thumbs-up" size={13} /> {p.rating_percent != null ? `${p.rating_percent}%` : '—'}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'rgba(255, 255, 255, 0.92)',
color: KT.text,
padding: '3px 9px', borderRadius: 999,
backdropFilter: 'blur(8px)',
}}>
<Icon name="gamepad" size={13} /> {(p.play_count || 0).toLocaleString('ru')}
</span>
</div>
</div>
{/* Title block */}
<div style={{ padding: '12px 14px' }}>
<div style={{
fontSize: 15, fontWeight: 800,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
color: KT.text,
letterSpacing: -0.2,
}}>
{p.title}
</div>
<div style={{
fontSize: 12, color: KT.textMuted,
marginTop: 2, fontWeight: 600,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
<Icon name="user" size={13} /> {p.author_username || `#${p.user_id}`}
</div>
</div>
</div>
);
};
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 (
<div style={{
flex: 1, minWidth: 200, maxWidth: 460,
position: 'relative',
}}>
<span style={{
position: 'absolute', left: 14, top: '50%',
transform: 'translateY(-50%)',
fontSize: 16, color: focus ? KT.accent : KT.textMuted,
pointerEvents: 'none',
transition: 'color 200ms ease',
}}><Icon name="search" size={14} /></span>
<input
type="text"
value={value}
onChange={(e) => 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',
}}
/>
</div>
);
};
// ================================================================
// === 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 (
<div
ref={wrapRef}
style={{ position: 'relative', display: 'inline-block' }}
>
{/* Trigger pill */}
<button
type="button"
onClick={() => 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'),
}}
>
<span style={{ fontSize: 13, fontWeight: 700, color: KT.text }}>
{label}
</span>
<span style={{
fontSize: 10, color: KT.textMuted,
transform: open ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform 200ms ease',
display: 'inline-block',
}}></span>
</button>
{/* Dropdown menu */}
{open && (
<div
role="listbox"
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
minWidth: 220,
background: KT.glassDark,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusLg,
boxShadow: KT.shadowLg,
padding: 6,
zIndex: 100,
animation: 'kubikonFadeInScale 180ms cubic-bezier(0.34, 1.56, 0.64, 1)',
transformOrigin: 'top right',
}}
>
{options.map(o => (
<FilterOption
key={o.value}
option={o}
selected={String(o.value) === String(value)}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
/>
))}
</div>
)}
</div>
);
};
const FilterOption = ({ option, selected, onClick }) => {
const [hovered, setHovered] = useState(false);
return (
<button
type="button"
role="option"
aria-selected={selected}
onClick={onClick}
onMouseEnter={() => 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',
}}
>
<span style={{ flex: 1 }}>{option.label}</span>
{selected && (
<span style={{ fontSize: 14, fontWeight: 800 }}><Icon name="check" size={14} /></span>
)}
</button>
);
};
// ================================================================
// === Skeleton grid ===
// ================================================================
const SkeletonGrid = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 18,
}}>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusLg,
overflow: 'hidden',
boxShadow: KT.shadow,
animation: `kubikonFadeIn 400ms ease ${i * 60}ms both`,
}}>
<div style={skeletonStyle({
aspectRatio: '4/3', borderRadius: 0,
})} />
<div style={{ padding: '12px 14px' }}>
<div style={skeletonStyle({ height: 16, width: '70%', marginBottom: 8 })} />
<div style={skeletonStyle({ height: 12, width: '40%' })} />
</div>
</div>
))}
</div>
);
// ================================================================
// === Empty state ===
// ================================================================
const EmptyState = ({ searchMode }) => (
<div style={{
padding: 80, textAlign: 'center',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusLg,
color: KT.textSecondary,
animation: 'kubikonFadeInScale 400ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
<div style={{
marginBottom: 20,
animation: 'kubikonFloat 4s ease-in-out infinite',
display: 'inline-block',
}}>
{searchMode
? <Icon emoji="🔍" size={80} />
: <Icon emoji="🌱" size={80} />}
</div>
<div style={{
fontSize: 22, fontWeight: 800, color: KT.text, marginBottom: 8,
letterSpacing: -0.3,
}}>
{searchMode ? 'Ничего не найдено' : 'Лента пока пуста'}
</div>
<div style={{ fontSize: 14, maxWidth: 420, margin: '0 auto' }}>
{searchMode
? 'Попробуй другой запрос или сними фильтры'
: 'Здесь скоро появятся первые игры от участников Рублокса'}
</div>
</div>
);
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 (
<div style={{
position: 'fixed', inset: 0, zIndex: 3000,
background: 'rgba(15, 23, 42, 0.55)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
animation: 'kubikonFadeIn 200ms ease',
}}>
<style>{KUBIKON_KEYFRAMES}</style>
<div style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radius2xl,
padding: 0,
maxWidth: 480, width: '100%',
color: KT.text,
fontFamily: KT.font,
maxHeight: '92vh', overflowY: 'auto',
boxShadow: KT.shadowXl,
animation: 'kubikonFadeInScale 320ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
<div style={{
background: KT.gradientBrand,
backgroundSize: '200% 200%',
animation: 'kubikonGradientShift 12s ease-in-out infinite',
padding: '28px 24px',
borderRadius: `${KT.radius2xl}px ${KT.radius2xl}px 0 0`,
textAlign: 'center',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
marginBottom: 4, color: '#fff',
filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.20))',
animation: 'kubikonFloat 5s ease-in-out infinite',
}}><Icon emoji="🎂" size={56} /></div>
<h2 style={{
margin: 0, fontSize: 24, fontWeight: 900, color: '#fff',
letterSpacing: -0.5,
}}>
Расскажи свой возраст
</h2>
<div style={{
fontSize: 13, color: 'rgba(255,255,255,0.92)',
marginTop: 6,
}}>
Покажем игры, подходящие именно тебе
</div>
</div>
<div style={{ padding: '24px' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<DobSelect label="День" value={day} onChange={setDay}
options={Array.from(
{ length: daysInMonth(month, year) },
(_, i) => ({ value: String(i + 1), label: String(i + 1) })
)} flex={1} />
<DobSelect label="Месяц" value={month} onChange={setMonth}
options={MONTHS_RU.map((m, i) => ({
value: String(i + 1), label: m,
}))} flex={1.6} />
<DobSelect label="Год" value={year} onChange={setYear}
options={Array.from(
{ length: CURRENT_YEAR - 1920 + 1 },
(_, i) => {
const y = CURRENT_YEAR - i;
return { value: String(y), label: String(y) };
}
)} flex={1} />
</div>
{description ? (
<div style={{
background: KT.accentSoft,
border: `1px solid ${KT.accent}`,
borderRadius: KT.radiusLg,
padding: 16, marginBottom: 16,
display: 'flex', alignItems: 'center', gap: 14,
animation: 'kubikonFadeInScale 240ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
<div><Icon emoji={description.emoji} size={36} /></div>
<div style={{ flex: 1 }}>
<div style={{
fontSize: 11, color: KT.accent,
textTransform: 'uppercase', letterSpacing: 1, fontWeight: 700,
}}>
{description.label} · {previewAge}{' '}
{previewAge === 1 ? 'год'
: (previewAge >= 2 && previewAge <= 4) ? 'года' : 'лет'}
</div>
<div style={{ fontSize: 13, color: KT.text, marginTop: 2 }}>
{description.text}
</div>
</div>
</div>
) : (
<div style={{
background: KT.bgMuted,
border: `1px dashed ${KT.borderStrong}`,
borderRadius: KT.radiusLg,
padding: 16, marginBottom: 16,
textAlign: 'center',
fontSize: 13, color: KT.textSecondary,
}}>
Выбери день, месяц и год мы покажем твой уровень
</div>
)}
{error && (
<div style={{
background: KT.dangerLight,
border: `1px solid ${KT.danger}`,
borderRadius: KT.radius,
padding: '8px 12px', marginBottom: 14,
fontSize: 12, color: KT.danger,
}}>
<Icon name="warning" size={13} /> {error}
</div>
)}
<div style={{
fontSize: 11, color: KT.textMuted,
marginBottom: 16, lineHeight: 1.5,
}}>
Дата рождения хранится только для возрастной фильтрации.
Обработка регулируется{' '}
<Link to="/privacy-policy" target="_blank" style={{
color: KT.accent, textDecoration: 'underline',
}}>
Политикой обработки ПД
</Link>, на которую ты дал согласие при регистрации.
</div>
<button
onClick={submit}
disabled={!canSubmit}
onMouseEnter={() => 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 ? 'Сохранение…' : <><Icon name="rocket" size={13} /> Поехали!</>}
</button>
</div>
</div>
</div>
);
};
const DobSelect = ({ label, value, onChange, options, flex }) => (
<div style={{ flex, minWidth: 0 }}>
<div style={{
fontSize: 11, color: KT.textSecondary, marginBottom: 4,
letterSpacing: 0.5, fontWeight: 600,
}}>{label}</div>
<select
value={value}
onChange={(e) => 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',
}}
>
<option value=""></option>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
/**
* 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 (
<div style={{ marginTop: 24 }}>
{!hideHeader && <CategoryHeader title={title} subtitle={subtitle} accent={accent} emoji={emoji} />}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 14,
}}>
{[1, 2, 3, 4].map(i => (
<div key={i} style={{
...skeletonStyle,
aspectRatio: '4/3',
borderRadius: KT.radiusLg,
}} />
))}
</div>
</div>
);
}
if (!items.length) return null;
if (layout === 'carousel') {
return (
<div style={{ marginTop: 28 }}>
{!hideHeader && <CategoryHeader title={title} subtitle={subtitle} accent={accent} emoji={emoji} />}
<CarouselScroll accent={accent}>
{items.map((p, i) => (
<div key={p.id} style={{
flex: '0 0 260px',
minWidth: 260,
scrollSnapAlign: 'start',
}}>
<GameCard project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 30, 300)} />
</div>
))}
</CarouselScroll>
</div>
);
}
if (layout === 'mosaic') {
// 1 крупная слева + 4 маленьких в сетке 2×2 справа
const big = items[0];
const smalls = items.slice(1, 5);
return (
<div style={{ marginTop: 28 }}>
{!hideHeader && <CategoryHeader title={title} subtitle={subtitle} accent={accent} emoji={emoji} />}
<div style={{
display: 'grid',
gridTemplateColumns: '2fr 1fr 1fr',
gap: 14,
minHeight: 380,
}}>
<div style={{ gridRow: 'span 2', minHeight: 380 }}>
<GameCard project={big} rank={0} sort="popular"
onClick={() => onCardClick(big)} />
</div>
{smalls.map((p, i) => (
<GameCard key={p.id} project={p} rank={i + 1} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min((i + 1) * 30, 200)} />
))}
</div>
</div>
);
}
if (layout === 'spotlight') {
// 3 крупные карточки в ряд
const top3 = items.slice(0, 3);
return (
<div style={{ marginTop: 28 }}>
{!hideHeader && <CategoryHeader title={title} subtitle={subtitle} accent={accent} emoji={emoji} />}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 18,
}}>
{top3.map((p, i) => (
<GameCard key={p.id} project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 50, 150)} />
))}
</div>
</div>
);
}
// grid (default)
return (
<div style={{ marginTop: 28 }}>
{!hideHeader && <CategoryHeader title={title} subtitle={subtitle} accent={accent} emoji={emoji} />}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 14,
}}>
{items.slice(0, 8).map((p, i) => (
<GameCard key={p.id} project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 25, 200)} />
))}
</div>
</div>
);
};
/**
* Заголовок категории — крупный, с эмоджи-бейджем, градиент-текстом
* и анимированной разделительной линией.
*/
const CategoryHeader = ({ title, subtitle, accent, emoji }) => (
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
margin: '0 0 18px',
position: 'relative',
animation: 'kubikonFadeIn 500ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
{emoji && (
<div style={{
flexShrink: 0,
width: 56, height: 56,
borderRadius: 16,
background: `linear-gradient(135deg, ${accent}, ${accent}aa)`,
boxShadow: `0 8px 22px ${accent}55`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff',
animation: 'kubikonFloatY 3s ease-in-out infinite',
}}>
<Icon emoji={emoji} size={28} strokeWidth={2} />
</div>
)}
<div style={{ flex: 1 }}>
<h2 style={{
margin: 0,
fontSize: 26, fontWeight: 900,
color: KT.text,
letterSpacing: -0.5,
lineHeight: 1.1,
}}>{title}</h2>
{subtitle && (
<div style={{
marginTop: 4,
fontSize: 13, color: KT.textSecondary,
fontWeight: 600,
}}>{subtitle}</div>
)}
</div>
<div style={{
flex: 1, maxWidth: 200, height: 3, borderRadius: 3,
background: `linear-gradient(90deg, ${accent}, ${accent}00)`,
}} />
</div>
);
/**
* Чипы-теги для быстрого перехода к секциям.
*/
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 (
<div style={{
display: 'flex', gap: 10, overflowX: 'auto',
paddingBottom: 8,
margin: '8px 0 24px',
scrollbarWidth: 'thin',
animation: 'kubikonFadeIn 600ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
{chips.map((c, i) => (
<button
key={c.id}
onClick={() => 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}</button>
))}
</div>
);
};
/**
* 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 (
<div style={{
margin: '0 0 32px',
position: 'relative',
borderRadius: 24,
overflow: 'hidden',
cursor: 'pointer',
background: 'linear-gradient(135deg, #1a1f4a 0%, #0f1338 100%)',
boxShadow: hovered
? '0 30px 80px rgba(51, 87, 255, 0.42)'
: '0 16px 50px rgba(51, 87, 255, 0.28)',
transform: hovered ? 'translateY(-4px)' : 'translateY(0)',
transition: 'all 320ms cubic-bezier(0.34, 1.56, 0.64, 1)',
animation: 'kubikonFadeInScale 700ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => onCardClick(game)}
>
{/* Анимированный градиентный фон */}
<div style={{
position: 'absolute', inset: 0,
background: 'radial-gradient(circle at 20% 50%, rgba(51,87,255,0.4), transparent 50%), '
+ 'radial-gradient(circle at 80% 30%, rgba(236,72,153,0.3), transparent 50%)',
animation: 'kubikonGradientShift 10s ease-in-out infinite',
}} />
<div style={{
position: 'relative', zIndex: 1,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.2fr) minmax(0, 1fr)',
gap: 0,
minHeight: 320,
}}>
{/* Обложка */}
<div style={{
position: 'relative',
minHeight: 280,
overflow: 'hidden',
}}>
{game.thumbnail ? (
<img
src={game.thumbnail}
alt={game.title}
style={{
width: '100%', height: '100%',
objectFit: 'cover',
transform: hovered ? 'scale(1.08)' : 'scale(1.02)',
transition: 'transform 800ms cubic-bezier(0.2, 0.8, 0.4, 1)',
}}
/>
) : (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 120,
}}><Icon name="gamepad" size={14} /></div>
)}
{/* Бейдж «ИГРА ДНЯ» */}
<div style={{
position: 'absolute', top: 20, left: 20,
padding: '8px 18px',
background: 'linear-gradient(135deg, #ffd700, #ff8833)',
borderRadius: 999,
color: '#1a1338',
fontWeight: 900, fontSize: 13,
letterSpacing: 1,
boxShadow: '0 8px 22px rgba(255, 215, 0, 0.55)',
animation: 'kubikonPulseRing 2s ease-in-out infinite',
}}><Icon name="crown" size={13} /> ИГРА ДНЯ</div>
</div>
{/* Инфо */}
<div style={{
padding: '40px 32px',
display: 'flex', flexDirection: 'column', justifyContent: 'center',
gap: 16,
}}>
<div style={{
fontSize: 11, fontWeight: 800, letterSpacing: 2,
color: '#ffd700', textTransform: 'uppercase',
opacity: 0.9,
}}><Icon name="flame" size={13} /> Самая горячая</div>
<h2 style={{
margin: 0,
fontSize: 38, fontWeight: 900,
color: '#fff',
letterSpacing: -1,
lineHeight: 1.05,
}}>{game.title}</h2>
{game.description && (
<p style={{
margin: 0,
fontSize: 14,
color: 'rgba(255, 255, 255, 0.78)',
lineHeight: 1.55,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>{game.description}</p>
)}
<div style={{
display: 'flex', gap: 16, alignItems: 'center',
marginTop: 8,
}}>
<div style={{
padding: '12px 28px',
background: 'linear-gradient(135deg, #3357ff, #1e2da5)',
borderRadius: 12,
color: '#fff',
fontWeight: 800, fontSize: 16,
boxShadow: '0 8px 22px rgba(51, 87, 255, 0.55)',
display: 'inline-flex', alignItems: 'center', gap: 8,
transition: 'all 200ms ease',
transform: hovered ? 'scale(1.05)' : 'scale(1)',
}}>
<Icon name="play" size={16} /> Играть
</div>
<div style={{
display: 'flex', gap: 14, fontSize: 13,
color: 'rgba(255, 255, 255, 0.78)', fontWeight: 700,
}}>
<span><Icon name="visible" size={13} /> {(game.play_count || 0).toLocaleString('ru')}</span>
<span><Icon name="heart" size={13} /> {(game.likes_count || 0).toLocaleString('ru')}</span>
</div>
</div>
</div>
</div>
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 32 }}>
<CategoryHeader title={title} subtitle={subtitle} accent="#ffd700" emoji="🏆" />
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 14, alignItems: 'end',
position: 'relative',
}}>
{PODIUM.map((slot, idx) => (
<PodiumCard key={slot.p.id} slot={slot} idx={idx}
onClick={() => onCardClick(slot.p)} />
))}
</div>
</div>
);
};
const PodiumCard = ({ slot, idx, onClick }) => {
const [hovered, setHovered] = useState(false);
const { p, place, color, emoji, height } = slot;
return (
<div
onClick={onClick}
onMouseEnter={() => 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',
}}
>
{/* Бейдж места */}
<div style={{
position: 'absolute', top: -10, left: '50%',
transform: 'translateX(-50%)',
width: 42, height: 42,
borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, ${color}cc)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff',
boxShadow: `0 6px 18px ${color}88`,
zIndex: 3,
animation: hovered ? 'kubikonPulseRing 1.5s ease-in-out infinite' : 'none',
}}><Icon emoji={emoji} size={22} /></div>
{/* Обложка-карточка */}
<div style={{
background: KT.bgPage,
border: `3px solid ${hovered ? color : color + '40'}`,
borderRadius: 18,
overflow: 'hidden',
boxShadow: hovered
? `0 20px 50px ${color}55`
: `0 8px 24px ${color}25`,
transform: hovered ? 'translateY(-8px) scale(1.03)' : 'translateY(0) scale(1)',
transition: 'all 300ms cubic-bezier(0.34, 1.56, 0.64, 1)',
marginTop: 22,
}}>
<div style={{
aspectRatio: '4/3',
background: '#e2e8f0',
overflow: 'hidden',
position: 'relative',
}}>
{p.thumbnail ? (
<img src={p.thumbnail} alt={p.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 56,
}}><Icon name="gamepad" size={14} /></div>
)}
<div style={{
position: 'absolute', inset: 0,
background: `linear-gradient(180deg, transparent 50%, ${color}33 100%)`,
}} />
</div>
<div style={{ padding: '14px 16px' }}>
<div style={{
fontSize: place === 1 ? 18 : 16, fontWeight: 800,
color: KT.text,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
marginBottom: 4,
}}>{p.title}</div>
<div style={{
display: 'flex', gap: 12, fontSize: 12,
color: KT.textSecondary, fontWeight: 700,
}}>
<span><Icon name="visible" size={13} /> {(p.play_count || 0).toLocaleString('ru')}</span>
<span><Icon name="heart" size={13} /> {(p.likes_count || 0).toLocaleString('ru')}</span>
</div>
</div>
</div>
{/* «Подставка» пьедестала */}
<div style={{
marginTop: 8,
height: place === 1 ? 50 : place === 2 ? 35 : 25,
background: `linear-gradient(180deg, ${color}, ${color}88)`,
borderRadius: '6px 6px 0 0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 900, fontSize: 24, color: '#fff',
textShadow: '0 2px 6px rgba(0,0,0,0.3)',
boxShadow: `inset 0 4px 8px rgba(255,255,255,0.25)`,
}}>{place}</div>
</div>
);
};
/**
* SectionBanner — широкий горизонтальный баннер-разделитель между секциями.
* Гигантский эмоджи + заголовок + градиент-фон + анимация.
*/
const SectionBanner = ({ emoji, title, subtitle, gradient }) => (
<div style={{
position: 'relative',
margin: '40px 0 16px',
padding: '32px 40px',
background: gradient,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 20px 50px rgba(0,0,0,0.18)',
animation: 'kubikonFadeInScale 700ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
{/* Анимированный декор-кружок */}
<div style={{
position: 'absolute', top: -60, right: -60,
width: 200, height: 200, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,255,255,0.18), transparent 70%)',
animation: 'kubikonFloatY 4s ease-in-out infinite',
}} />
<div style={{
position: 'absolute', bottom: -40, left: '40%',
width: 140, height: 140, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,255,255,0.12), transparent 70%)',
animation: 'kubikonFloatY 5s ease-in-out infinite reverse',
}} />
<div style={{
position: 'relative', zIndex: 1,
display: 'flex', alignItems: 'center', gap: 24,
}}>
<div style={{
color: '#fff',
filter: 'drop-shadow(0 6px 18px rgba(0,0,0,0.25))',
animation: 'kubikonFloatY 3s ease-in-out infinite',
}}><Icon emoji={emoji} size={64} /></div>
<div style={{ flex: 1 }}>
<h2 style={{
margin: 0,
fontSize: 32, fontWeight: 900,
color: '#fff',
letterSpacing: -0.8,
textShadow: '0 2px 12px rgba(0,0,0,0.18)',
lineHeight: 1.1,
}}>{title}</h2>
{subtitle && (
<div style={{
marginTop: 6,
fontSize: 15, fontWeight: 600,
color: 'rgba(255,255,255,0.88)',
}}>{subtitle}</div>
)}
</div>
</div>
</div>
);
/**
* 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 (
<div style={{
margin: '36px 0 0',
padding: '28px 24px',
background: 'linear-gradient(135deg, rgba(255,184,74,0.08), rgba(255,215,0,0.04))',
border: '2px solid rgba(255,184,74,0.35)',
borderRadius: 24,
position: 'relative',
overflow: 'hidden',
animation: 'kubikonFadeInScale 700ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
{/* Звёзды-декор */}
{[...Array(6)].map((_, i) => (
<div key={i} style={{
position: 'absolute',
top: `${10 + i * 15}%`,
left: `${(i * 17) % 90}%`,
fontSize: 14 + (i % 3) * 4,
opacity: 0.3,
animation: `kubikonFloatY ${3 + (i % 3)}s ease-in-out infinite`,
pointerEvents: 'none',
}}><Icon name="star" size={14} /></div>
))}
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
marginBottom: 20, position: 'relative',
}}>
<div style={{
flexShrink: 0, width: 64, height: 64,
borderRadius: 18,
background: 'linear-gradient(135deg, #ffd700, #ff8833)',
boxShadow: '0 12px 28px rgba(255,184,74,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 36,
animation: 'kubikonPulseRing 2.5s ease-in-out infinite',
}}><Icon name="star" size={14} /></div>
<div style={{ flex: 1 }}>
<div style={{
fontSize: 11, fontWeight: 900, letterSpacing: 2,
color: '#ff8833', textTransform: 'uppercase',
}}>Высший рейтинг</div>
<h2 style={{
margin: '4px 0 0',
fontSize: 28, fontWeight: 900, color: KT.text,
letterSpacing: -0.5,
background: 'linear-gradient(120deg, #ffd700, #ff8833)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}>Лучшие игры</h2>
<div style={{
marginTop: 4, fontSize: 13,
color: KT.textSecondary, fontWeight: 600,
}}>Игры, которые игроки оценили выше всего</div>
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 14,
position: 'relative',
}}>
{items.map((p, i) => (
<GameCard key={p.id} project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 60, 360)} />
))}
</div>
</div>
);
};
/**
* 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 (
<div style={{
background: bgGradient,
border: `2px solid ${accent}30`,
borderRadius: 20,
padding: 20,
position: 'relative',
overflow: 'hidden',
animation: 'kubikonFadeInUp 600ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
<div style={{ marginBottom: 14 }}>
<h3 style={{
margin: 0, fontSize: 22, fontWeight: 900,
color: KT.text, letterSpacing: -0.4,
}}>{title}</h3>
<div style={{
fontSize: 13, color: KT.textSecondary,
fontWeight: 600, marginTop: 2,
}}>{subtitle}</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 10,
}}>
{items.slice(0, 4).map((p, i) => (
<MiniGameCard key={p.id} project={p} accent={accent}
onClick={() => onCardClick(p)} delay={i * 60} />
))}
</div>
</div>
);
};
/** Мини-карточка для CompactGenreCard. */
const MiniGameCard = ({ project, accent, onClick, delay }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => 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`,
}}
>
<div style={{
aspectRatio: '4/3',
background: '#e2e8f0',
position: 'relative', overflow: 'hidden',
}}>
{project.thumbnail ? (
<img src={project.thumbnail} alt={project.title}
style={{ width: '100%', height: '100%', objectFit: 'cover',
transform: hovered ? 'scale(1.08)' : 'scale(1)',
transition: 'transform 500ms cubic-bezier(0.2,0.8,0.4,1)' }} />
) : (
<div style={{ position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 32 }}><Icon name="gamepad" size={14} /></div>
)}
</div>
<div style={{ padding: '8px 10px' }}>
<div style={{
fontSize: 13, fontWeight: 700, color: KT.text,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{project.title}</div>
</div>
</div>
);
};
/**
* CreateBanner — призывный CTA-баннер «Создай свою игру».
*/
const CreateBanner = ({ onClick }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => 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) => (
<div key={i} style={{
position: 'absolute',
top: `${20 + i * 12}%`,
left: `${(i * 23) % 90}%`,
opacity: 0.25, color: '#fff',
animation: `kubikonFloatY ${3 + (i % 3)}s ease-in-out infinite`,
pointerEvents: 'none',
}}><Icon name={['gamepad', 'rocket', 'palette', 'sparkles', 'star'][i]} size={28 + (i % 3) * 10} /></div>
))}
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{
fontSize: 12, fontWeight: 800, letterSpacing: 2,
color: 'rgba(255,255,255,0.88)', textTransform: 'uppercase',
}}><Icon name="wrench" size={13} /> Конструктор</div>
<h2 style={{
margin: '6px 0 8px',
fontSize: 34, fontWeight: 900,
color: '#fff', letterSpacing: -0.8, lineHeight: 1.1,
}}>Хочешь создать свою игру?</h2>
<p style={{
margin: 0, fontSize: 15,
color: 'rgba(255,255,255,0.92)', lineHeight: 1.5,
maxWidth: 600,
}}>Открой Кубикон-студию и собери игру из блоков, моделей
и скриптов. Опубликуй и набирай рекорды от друзей!</p>
</div>
<div style={{
position: 'relative', zIndex: 1,
padding: '16px 32px',
background: '#fff',
color: '#3357ff',
fontWeight: 900, fontSize: 16,
borderRadius: 999,
whiteSpace: 'nowrap',
boxShadow: hovered
? '0 12px 28px rgba(0,0,0,0.25)'
: '0 6px 16px rgba(0,0,0,0.18)',
transform: hovered ? 'scale(1.05)' : 'scale(1)',
transition: 'all 200ms ease',
display: 'inline-flex', alignItems: 'center', gap: 8,
}}><Icon name="rocket" size={13} /> Начать создавать</div>
</div>
);
};
/**
* 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 (
<div
style={{ position: 'relative' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<style>{scrollbarStyle}</style>
<div
ref={scrollRef}
className={scrollbarClass}
style={{
display: 'flex', gap: 14, overflowX: 'auto',
paddingBottom: 14,
scrollSnapType: 'x mandatory',
scrollBehavior: 'smooth',
scrollbarColor: `${accent} rgba(0,0,0,0.08)`,
}}
>
{children}
</div>
{/* Кнопка влево */}
{canLeft && (
<button
onClick={() => 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';
}}
></button>
)}
{/* Кнопка вправо */}
{canRight && (
<button
onClick={() => 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';
}}
></button>
)}
</div>
);
};
/**
* 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 (
<div style={{
position: 'relative', zIndex: 1,
margin: '8px 0 18px',
padding: '20px 26px',
background: e.gradient || 'linear-gradient(135deg, #ec4899, #9966ff, #3357ff)',
backgroundSize: '200% 200%',
borderRadius: 18,
cursor: 'pointer',
boxShadow: '0 16px 40px rgba(153, 102, 255, 0.4)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 16, alignItems: 'center',
overflow: 'hidden',
animation: 'kubikonGradientShift 8s ease-in-out infinite, '
+ 'kubikonFadeInScale 700ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}} onClick={() => e.cta_url && onCardClick(e.cta_url)}>
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{
fontSize: 11, fontWeight: 800, letterSpacing: 2,
color: 'rgba(255,255,255,0.85)', textTransform: 'uppercase',
}}><Icon emoji="🎉" size={13} /> СОБЫТИЕ</div>
<h3 style={{
margin: '4px 0 4px', fontSize: 22, fontWeight: 900,
color: '#fff', letterSpacing: -0.4,
}}>{e.title}</h3>
<div style={{
fontSize: 13, color: 'rgba(255,255,255,0.85)',
}}>{e.subtitle}</div>
</div>
{e.cta_label && (
<div style={{
padding: '10px 22px', background: '#fff', color: '#3357ff',
fontWeight: 800, fontSize: 14, borderRadius: 999,
whiteSpace: 'nowrap',
boxShadow: '0 4px 12px rgba(0,0,0,0.18)',
}}>{e.cta_label} <Icon name="arrow-right" size={13} style={{ verticalAlign: '-2px' }} /></div>
)}
</div>
);
};
/**
* 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 (
<div onClick={() => 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 = '';
}}>
<div style={{
aspectRatio: '4/3', borderRadius: 12, overflow: 'hidden',
background: '#e2e8f0',
}}>
{game.thumbnail ? (
<img src={game.thumbnail} alt={game.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ display: 'flex', alignItems: 'center',
justifyContent: 'center', height: '100%', fontSize: 40 }}><Icon name="gamepad" size={14} /></div>
)}
</div>
<div>
<div style={{
fontSize: 11, fontWeight: 800, letterSpacing: 2,
color: '#06b6d4', textTransform: 'uppercase',
}}><Icon name="play" size={13} /> Продолжить</div>
<div style={{
fontSize: 22, fontWeight: 900, color: KT.text,
margin: '4px 0 4px',
}}>{game.title}</div>
<div style={{ fontSize: 12, color: KT.textSecondary }}>
<Icon name="user" size={13} /> {game.author_username || '...'} · <Icon name="visible" size={13} /> {game.play_count || 0}
</div>
</div>
<div style={{
padding: '10px 22px', borderRadius: 999,
background: 'linear-gradient(135deg, #06b6d4, #22d97a)',
color: '#fff', fontWeight: 800, fontSize: 14,
boxShadow: '0 8px 22px rgba(6,182,212,0.4)',
}}>Играть</div>
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 28 }}>
<CategoryHeader title="Недавно играли" subtitle="Последние сессии"
accent="#06b6d4" emoji="🕒" />
<CarouselScroll accent="#06b6d4">
{items.map((p, i) => (
<div key={p.id} style={{
flex: '0 0 220px', minWidth: 220,
scrollSnapAlign: 'start',
}}>
<GameCard project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 30, 200)} />
</div>
))}
</CarouselScroll>
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 28 }}>
<CategoryHeader title="Моё избранное" subtitle="Сохранённые игры"
accent="#ef4444" emoji="❤️" />
<CarouselScroll accent="#ef4444">
{items.map((p, i) => (
<div key={p.id} style={{
flex: '0 0 240px', minWidth: 240,
scrollSnapAlign: 'start',
}}>
<GameCard project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 30, 200)} />
</div>
))}
</CarouselScroll>
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 28 }}>
<CategoryHeader title="В тренде" subtitle="Резкий рост за 14 дней"
accent="#fb923c" emoji="📈" />
<CarouselScroll accent="#fb923c">
{items.map((p, i) => (
<div key={p.id} style={{
flex: '0 0 240px', minWidth: 240,
scrollSnapAlign: 'start',
}}>
<GameCard project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 30, 200)} />
</div>
))}
</CarouselScroll>
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 36 }}>
<CategoryHeader title="Подборки" subtitle="От редакции — для тебя"
accent="#a855f7" emoji="📚" />
{collections.map(col => (
<div key={col.id} style={{
marginBottom: 22,
padding: '18px',
background: `linear-gradient(135deg, ${col.cover_color}18, transparent)`,
border: `2px solid ${col.cover_color}40`,
borderRadius: 16,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12,
}}>
<div style={{
fontSize: 18, fontWeight: 900, color: KT.text,
}}>{col.title}</div>
<div style={{
fontSize: 13, color: KT.textSecondary, fontWeight: 600,
}}>{col.subtitle}</div>
</div>
<CarouselScroll accent={col.cover_color}>
{(col.projects || []).map((p, i) => (
<div key={p.id} style={{
flex: '0 0 220px', minWidth: 220,
scrollSnapAlign: 'start',
}}>
<GameCard project={p} rank={i} sort="popular"
onClick={() => onCardClick(p)}
animateDelay={Math.min(i * 30, 200)} />
</div>
))}
</CarouselScroll>
</div>
))}
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 36 }}>
<CategoryHeader title="Топ-авторы" subtitle="Кто двигает платформу"
accent="#eab308" emoji="👑" />
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 14,
}}>
{authors.map((a, i) => (
<div key={a.user_id}
onClick={() => 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';
}}
>
<div style={{
width: 44, height: 44, borderRadius: '50%',
background: i === 0
? 'linear-gradient(135deg, #ffd700, #ff8833)'
: i === 1 ? 'linear-gradient(135deg, #c0c0c0, #999999)'
: i === 2 ? 'linear-gradient(135deg, #cd7f32, #8b4513)'
: 'rgba(0,0,0,0.10)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 900, color: '#fff',
fontSize: i < 3 ? 22 : 16,
boxShadow: i < 3 ? `0 4px 12px ${
['#ffd700aa','#c0c0c0aa','#cd7f32aa'][i]}` : 'none',
}}>
{i === 0 ? <Icon emoji="🥇" size={22} />
: i === 1 ? <Icon emoji="🥈" size={22} />
: i === 2 ? <Icon emoji="🥉" size={22} />
: (i + 1)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: 800, fontSize: 14, color: KT.text,
whiteSpace: 'nowrap', overflow: 'hidden',
textOverflow: 'ellipsis',
}}>{a.username || ('id ' + a.user_id)}</div>
<div style={{
fontSize: 12, color: KT.textSecondary, marginTop: 2,
}}><Icon name="visible" size={13} /> {a.total_plays.toLocaleString('ru')}
· <Icon name="gamepad" size={13} /> {a.total_games}</div>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 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 (
<div style={{ marginTop: 36 }}>
<CategoryHeader
title={<><Icon name="radio" size={14} /><span style={{ color: '#dc2626' }}>LIVE</span> · Свежие рекорды</>}
subtitle="Кто только что побил время"
accent="#dc2626" emoji="🔴" />
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 12,
}}>
{activity.map((a, i) => (
<div key={i} style={{
background: KT.bgPage,
border: `1.5px solid ${KT.border}`,
borderRadius: 12,
padding: 12,
display: 'flex', alignItems: 'center', gap: 12,
cursor: 'pointer',
transition: 'all 200ms ease',
animation: `kubikonFadeInUp 500ms ease ${i * 40}ms both`,
}}
onClick={() => 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 = '';
}}
>
{/* Миниатюра обложки */}
<div style={{
width: 56, height: 42, borderRadius: 8,
overflow: 'hidden', flexShrink: 0,
background: '#e2e8f0',
}}>
{a.project_thumbnail ? (
<img src={a.project_thumbnail} alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ display: 'flex', alignItems: 'center',
justifyContent: 'center', height: '100%', fontSize: 22 }}><Icon name="gamepad" size={14} /></div>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: KT.text, fontWeight: 700,
overflow: 'hidden', whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
<span
onClick={(e) => {
e.stopPropagation();
onUserClick(a.user_id);
}}
style={{ color: '#3357ff', cursor: 'pointer' }}
>{a.username || 'игрок'}</span>
{' '}побил рекорд
</div>
<div style={{
fontSize: 12, color: KT.textSecondary,
whiteSpace: 'nowrap', overflow: 'hidden',
textOverflow: 'ellipsis', marginTop: 1,
}}>в «{a.project_title}»</div>
</div>
<div style={{
padding: '4px 10px', borderRadius: 999,
background: 'linear-gradient(135deg, #22d97a, #06b6d4)',
color: '#fff', fontWeight: 800, fontSize: 12,
fontFamily: 'ui-monospace, monospace',
}}><Icon name="clock" size={13} /> {formatTime(a.time_ms)}</div>
</div>
))}
</div>
</div>
);
};
/**
* 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 (
<div style={{
position: 'absolute', inset: 0,
pointerEvents: 'none',
overflow: 'hidden',
zIndex: 0,
}}>
{particles.map(p => (
<div key={p.id} style={{
position: 'absolute',
left: `${p.left}%`,
top: `${p.top}%`,
opacity: 0.5,
color: KT.accent,
animation: `kubikonParticleFloat ${p.duration}s ease-in-out ${p.delay}s infinite`,
filter: 'blur(0.3px)',
}}><Icon name={p.iconName} size={p.size} /></div>
))}
</div>
);
};
/**
* 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 (
<div style={{
position: 'relative', zIndex: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 14, marginBottom: 24,
animation: 'kubikonFadeInUp 700ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
{stats.map((s, i) => (
<StatCard key={s.label} {...s} delay={i * 90} />
))}
</div>
);
};
const StatCard = ({ icon, label, value, color, delay }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => 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`,
}}
>
{/* Декор-кружок в углу */}
<div style={{
position: 'absolute', top: -30, right: -30,
width: 80, height: 80, borderRadius: '50%',
background: `radial-gradient(circle, ${color}33, transparent 70%)`,
animation: hovered ? 'kubikonBgPulse 1.5s ease-in-out infinite' : 'none',
}} />
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
color,
animation: 'kubikonGlow 2s ease-in-out infinite',
}}><Icon name={icon} size={28} /></div>
<div>
<div style={{
fontSize: 22, fontWeight: 900, color: KT.text,
lineHeight: 1, letterSpacing: -0.5,
}}>
{typeof value === 'number'
? <CountUpNumber to={value} />
: <span style={{ color, animation: 'kubikonGlow 1.5s ease-in-out infinite' }}> LIVE</span>}
</div>
<div style={{
marginTop: 4, fontSize: 11, fontWeight: 700,
color: KT.textSecondary, letterSpacing: 0.3,
textTransform: 'uppercase',
}}>{label}</div>
</div>
</div>
</div>
);
};
/** Анимация числа от 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;