All checks were successful
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>
3768 lines
163 KiB
JavaScript
3768 lines
163 KiB
JavaScript
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;
|