studio/src/community/KubikonGamePage.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026).
Содержит изменения которые делались в процессе подготовки прод-окружения:

Фиксы импортов после выноса из minecraftia:
- Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/)
- Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/
- API.js скопирован из минки целиком (было 8 экспортов, стало 312)
- Добавлены PLAYER_URL, MyButton_1, недостающие компоненты
- Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require)

Структура ассетов:
- public/kubikon-templates/ → public/assets/kubikon-templates/
- public/kubikon-learn/ → public/assets/kubikon-learn/
- (код искал в /assets/, файлы лежали без /assets/)

Навигация роутов внутри студии:
- /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced)
- /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X

UI:
- Новый компонент StudioHeader (61px, как в минке) + копия favicon
- WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера
- SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке)
- Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык)

Документация:
- docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR
- docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API
- API_USAGE.md — список эндпоинтов backend
- README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/

.gitignore:
- public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:01:13 +03:00

1293 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import { formatRelative } from '../utils/kubikonTime';
import { useAuth } from '../auth/AuthContext.jsx';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType';
import KubikonLeaderboard from '../components/KubikonLeaderboard/KubikonLeaderboard';
import Icon from '../editor/Icon';
import {
KT, buttonPrimary, KUBIKON_KEYFRAMES, skeletonStyle,
} from '../utils/kubikonTheme';
/**
* KubikonGamePage — детальная страница игры Рублокс (wow-redesign).
* URL: /game/:id
*
* Структура:
* 1. Cinematic-hero: blur-фон из thumbnail на всю ширину + затемнение,
* на фоне крупный foreground-постер с 3D-tilt и кнопка ▶ Играть.
* 2. Side-панель с рейтингом, голосованием и метриками.
* 3. Описание игры (карточка-секция).
* 4. Комментарии (live, секция).
*/
const GENRES = {
platformer: 'Платформер', racing: 'Гонки', shooter: 'Шутер',
sandbox: 'Песочница', adventure: 'Приключение', puzzle: 'Головоломка',
tycoon: 'Тайкун', rpg: 'РПГ', other: 'Другое',
};
const KubikonGamePage = () => {
const { id } = useParams();
const navigate = useNavigate();
const projectId = Number(id);
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { isPhone, isTablet } = useDeviceType();
const compact = isPhone || isTablet;
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) navigate('/login', { replace: true });
}, [isAuthenticated, authLoading, navigate]);
const [meta, setMeta] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [vote, setVote] = useState(null);
const [likes, setLikes] = useState(0);
const [dislikes, setDislikes] = useState(0);
const [favorited, setFavorited] = useState(false);
const [playHovered, setPlayHovered] = useState(false);
const userId = (() => {
try {
const t = localStorage.getItem('Authorization');
if (!t) return null;
const p = jwtDecode(t.startsWith('Bearer ') ? t.slice(7) : t);
if (p?.guest === true || (typeof p?.sub === 'string' && p.sub.startsWith('guest_'))) {
return null;
}
return p?.id || p?.user_id || null;
} catch (e) { return null; }
})();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await Kubikon3DApi.getProjectForPlay(projectId, userId);
const m = res.data;
setMeta(m);
setLikes(m.likes_count || 0);
setDislikes(m.dislikes_count || 0);
if (userId) {
const vs = await Kubikon3DApi.getLikeStatus(projectId, userId);
setVote(vs.data?.vote || null);
try {
const fs = await Kubikon3DApi.getFavoriteStatus(projectId, userId);
setFavorited(!!fs.data?.favorited);
} catch (e) { /* ignore */ }
}
} catch (e) {
setError(e?.response?.data?.error || e?.message || 'Ошибка');
}
setLoading(false);
}, [projectId, userId]);
useEffect(() => { load(); }, [load]);
const handleVote = async (kind) => {
if (!userId) { navigate('/login'); return; }
try {
const res = await Kubikon3DApi.toggleLike(projectId, userId, kind);
setVote(res.data?.vote || null);
setLikes(res.data?.likes_count || 0);
setDislikes(res.data?.dislikes_count || 0);
} catch (e) { /* ignore */ }
};
const handleFavorite = async () => {
if (!userId) { navigate('/login'); return; }
const wasFav = favorited;
setFavorited(!wasFav); // оптимистично
try {
const r = await Kubikon3DApi.toggleFavorite(projectId, userId);
setFavorited(!!r.data?.favorited);
} catch (e) {
setFavorited(wasFav);
}
};
const totalVotes = likes + dislikes;
const ratingPct = totalVotes > 0
? Math.round(100 * likes / totalVotes)
: null;
if (loading) {
return (
<div style={pageStyle}>
<style>{KUBIKON_KEYFRAMES}</style>
<Header compact={compact} />
<SkeletonHero />
</div>
);
}
if (error || !meta) {
return (
<div style={pageStyle}>
<style>{KUBIKON_KEYFRAMES}</style>
<Header compact={compact} />
<div style={{
maxWidth: 600, margin: '80px auto', padding: 48,
textAlign: 'center',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radius2xl,
boxShadow: KT.shadowLg,
}}>
<div style={{
fontSize: 72, marginBottom: 16,
animation: 'kubikonFloat 4s ease-in-out infinite',
}}><Icon name="warning" size={14} /></div>
<div style={{
fontSize: 22, fontWeight: 800, color: KT.text, marginBottom: 8,
}}>
Не удалось загрузить
</div>
<div style={{ marginBottom: 24, color: KT.textSecondary }}>
{error || 'Игра не найдена'}
</div>
<Link to="/kubikon" style={{
display: 'inline-block', padding: '12px 22px',
background: KT.gradientBrand,
color: '#fff',
borderRadius: KT.radius,
textDecoration: 'none', fontSize: 14, fontWeight: 700,
boxShadow: KT.shadowAccent,
}}><Icon name="arrow-left" size={13} /> В ленту</Link>
</div>
</div>
);
}
return (
<div style={pageStyle}>
<style>{KUBIKON_KEYFRAMES}</style>
<Header compact={compact} />
{/* === CINEMATIC HERO с blur-фоном === */}
<CinematicHero
meta={meta}
ratingPct={ratingPct}
totalVotes={totalVotes}
playHovered={playHovered}
setPlayHovered={setPlayHovered}
onPlay={() => navigate(`/play/${projectId}`)}
/>
{/* === Контент-секция === */}
<div style={{
maxWidth: 1200, margin: '0 auto',
padding: compact ? '0 12px 32px' : '0 24px 64px',
animation: 'kubikonHeroSlide 480ms cubic-bezier(0.2, 0.8, 0.4, 1) both',
animationDelay: '120ms',
}}>
<div style={{
display: 'grid',
gridTemplateColumns: compact
? '1fr'
: 'minmax(0, 2fr) minmax(0, 1fr)',
gap: compact ? 14 : 24,
// На мобиле hero compact, не сдвигаем контент вверх
marginTop: compact ? 8 : -80,
position: 'relative', zIndex: 5,
}}>
{/* На мобиле — порядок другой: сначала рейтинг (важно
видеть до решения играть/нет), потом описание,
метрики, автор, комментарии. */}
{compact ? (
<>
<RatingCard
ratingPct={ratingPct}
totalVotes={totalVotes}
likes={likes}
dislikes={dislikes}
vote={vote}
onVote={handleVote}
favorited={favorited}
onFavorite={handleFavorite}
/>
<Section title="Описание" gradient={KT.gradientCool}>
{meta.description?.trim() ? (
<div style={{
fontSize: 14, color: KT.text,
whiteSpace: 'pre-wrap', lineHeight: 1.55,
}}>
{meta.description}
</div>
) : (
<div style={{
fontSize: 13, color: KT.textMuted, fontStyle: 'italic',
}}>
Автор не оставил описания
</div>
)}
</Section>
<MetricsGrid meta={meta} />
<AuthorCard userId={meta.user_id} username={meta.author_username} />
<Section title="Таблица лидеров" gradient={KT.gradientHot}>
<KubikonLeaderboard
projectId={projectId}
limit={10}
currentUserId={userId}
style={{ background: 'transparent', border: 'none',
padding: 0, boxShadow: 'none' }}
/>
</Section>
<GameComments
projectId={projectId}
projectOwnerId={meta.user_id}
currentUserId={userId}
onRequestAuth={() => navigate('/login')}
/>
</>
) : (
<>
{/* Left: описание + комментарии */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<Section title="Описание" gradient={KT.gradientCool}>
{meta.description?.trim() ? (
<div style={{
fontSize: 16, color: KT.text,
whiteSpace: 'pre-wrap', lineHeight: 1.6,
}}>
{meta.description}
</div>
) : (
<div style={{
fontSize: 14, color: KT.textMuted, fontStyle: 'italic',
}}>
Автор не оставил описания
</div>
)}
</Section>
<Section title="Таблица лидеров" gradient={KT.gradientHot}>
<KubikonLeaderboard
projectId={projectId}
limit={10}
currentUserId={userId}
style={{ background: 'transparent', border: 'none',
padding: 0, boxShadow: 'none' }}
/>
</Section>
<GameComments
projectId={projectId}
projectOwnerId={meta.user_id}
currentUserId={userId}
onRequestAuth={() => navigate('/login')}
/>
</div>
{/* Right: side-панель с рейтингом и метриками */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<RatingCard
ratingPct={ratingPct}
totalVotes={totalVotes}
likes={likes}
dislikes={dislikes}
vote={vote}
onVote={handleVote}
/>
<MetricsGrid meta={meta} />
<AuthorCard userId={meta.user_id} username={meta.author_username} />
</div>
</>
)}
</div>
</div>
</div>
);
};
// =================================================================
// === CinematicHero ===
// =================================================================
const CinematicHero = ({ meta, ratingPct, totalVotes, playHovered, setPlayHovered, onPlay }) => {
const tiltRef = useRef(null);
const [tilt, setTilt] = useState({ rx: 0, ry: 0 });
const { isPhone, isTablet } = useDeviceType();
const compact = isPhone || isTablet;
const onMove = (e) => {
if (compact) return; // на мобиле tilt не нужен
const el = tiltRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
setTilt({ rx: -(y - 0.5) * 8, ry: (x - 0.5) * 8 });
};
const onLeave = () => setTilt({ rx: 0, ry: 0 });
return (
<div style={{
position: 'relative',
minHeight: compact ? 'auto' : 540,
overflow: 'hidden',
paddingTop: compact ? 12 : 24,
}}>
{/* Blur background из thumbnail */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: meta.thumbnail ? `url(${meta.thumbnail})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(60px) saturate(1.4)',
transform: 'scale(1.2)',
opacity: meta.thumbnail ? 0.55 : 0,
animation: 'kubikonFadeIn 600ms ease both',
}} />
{/* Если нет thumbnail — градиентный фон */}
{!meta.thumbnail && (
<div style={{
position: 'absolute', inset: 0,
background: KT.gradientHero,
backgroundSize: '200% 200%',
animation: 'kubikonGradientShift 16s ease infinite',
opacity: 0.85,
}} />
)}
{/* Затемнение и vignette */}
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(245,247,251,0.40) 0%, rgba(245,247,251,0.85) 70%, rgba(245,247,251,1) 100%)',
}} />
{/* Floating shapes — только на десктопе */}
{!compact && <FloatingShapes />}
<div style={{
position: 'relative', zIndex: 2,
maxWidth: 1200, margin: '0 auto',
padding: compact ? '12px 12px 0' : '24px 24px 0',
display: 'grid',
gridTemplateColumns: compact
? '1fr'
: 'minmax(0, 1.2fr) minmax(0, 1fr)',
gap: compact ? 16 : 40,
alignItems: 'center',
minHeight: compact ? 'auto' : 460,
}}>
{/* Foreground sharp poster */}
<div
ref={tiltRef}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{
aspectRatio: '16/10',
background: '#e2e8f0',
borderRadius: KT.radius2xl,
overflow: 'hidden',
boxShadow: '0 32px 80px rgba(15,23,42,0.32), 0 12px 32px rgba(15,23,42,0.18)',
position: 'relative',
transform: `perspective(1200px) rotateX(${tilt.rx}deg) rotateY(${tilt.ry}deg)`,
transition: 'transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
animation: 'kubikonHeroSlide 600ms cubic-bezier(0.2, 0.8, 0.4, 1) both',
cursor: 'pointer',
}}
onClick={onPlay}
>
{meta.thumbnail ? (
<img src={meta.thumbnail} alt={meta.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 120, color: '#fff',
background: KT.gradientBrand,
}}><Icon name="gamepad" size={14} /></div>
)}
{/* Play overlay при hover */}
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, transparent 60%, rgba(0,0,0,0.5) 100%)',
display: 'flex', alignItems: 'flex-end', padding: 20,
pointerEvents: 'none',
}}>
{ratingPct != null && (
<div style={{
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(12px)',
color: '#fff',
padding: '8px 14px',
borderRadius: 999,
fontSize: 14, fontWeight: 800,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span style={{ fontSize: 16 }}><Icon name="star" size={14} /></span>
{ratingPct}% {totalVotes} {pluralRu(totalVotes, ['голос', 'голоса', 'голосов'])}
</div>
)}
</div>
{/* Большой play в центре при hover */}
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: 0,
transition: 'opacity 200ms ease',
pointerEvents: 'none',
}}
className="kubikon-hero-play-overlay">
<div style={{
width: 96, height: 96, borderRadius: '50%',
background: 'rgba(51, 87, 255, 0.92)',
backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', paddingLeft: 6,
boxShadow: '0 16px 48px rgba(51,87,255,0.5)',
}}><Icon name="play" size={36} /></div>
</div>
</div>
{/* Right: title + play */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 18,
animation: 'kubikonHeroSlide 600ms cubic-bezier(0.2, 0.8, 0.4, 1) both',
animationDelay: '80ms',
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: KT.glassDark,
backdropFilter: 'blur(20px)',
border: `1px solid ${KT.borderSoft}`,
padding: '6px 14px',
borderRadius: 999,
fontSize: 12, fontWeight: 800,
color: KT.accent,
textTransform: 'uppercase',
letterSpacing: 1,
alignSelf: 'flex-start',
boxShadow: KT.shadowSm,
}}>
<span style={{ fontSize: 14 }}><Icon name="gamepad" size={14} /></span>
{GENRES[meta.genre] || 'Игра'}
</div>
<h1 style={{
margin: 0,
fontSize: 'clamp(36px, 5vw, 56px)',
fontWeight: 900,
color: KT.text,
lineHeight: 1.05,
letterSpacing: -1,
wordBreak: 'break-word',
textShadow: '0 2px 24px rgba(255,255,255,0.4)',
}}>
{meta.title}
</h1>
<Link to={`/user/${meta.user_id}`} style={{
display: 'inline-flex', alignItems: 'center', gap: 10,
color: KT.text, fontSize: 16, fontWeight: 700,
textDecoration: 'none',
background: KT.glassDark,
backdropFilter: 'blur(20px)',
border: `1px solid ${KT.borderSoft}`,
padding: '8px 14px',
borderRadius: 999,
alignSelf: 'flex-start',
boxShadow: KT.shadowSm,
}}>
<span style={{
width: 28, height: 28, borderRadius: '50%',
background: KT.gradientBrand,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 800,
}}>
{(meta.author_username || '?').slice(0, 1).toUpperCase()}
</span>
{meta.author_username || `Игрок #${meta.user_id}`}
</Link>
<button
onClick={onPlay}
onMouseEnter={() => setPlayHovered(true)}
onMouseLeave={() => setPlayHovered(false)}
style={{
...buttonPrimary(playHovered),
padding: '18px 32px',
fontSize: 20, fontWeight: 900,
alignSelf: 'flex-start',
display: 'inline-flex', alignItems: 'center', gap: 12,
animation: 'kubikonPulseGlow 2.4s ease-in-out infinite',
}}
>
<Icon name="play" size={22} />
Играть сейчас
</button>
<div style={{
display: 'flex', gap: 16, alignItems: 'center',
flexWrap: 'wrap',
marginTop: 4,
}}>
<HeroStatPill icon="🎮" value={(meta.play_count || 0).toLocaleString('ru')} label="плеев" />
{ratingPct != null && (
<HeroStatPill
icon="⭐"
value={`${ratingPct}%`}
label="рейтинг"
color={ratingColor(ratingPct)}
/>
)}
<HeroStatPill icon="🔞" value={`${meta.age_rating || 12}+`} label="" />
</div>
</div>
</div>
</div>
);
};
const HeroStatPill = ({ icon, value, label, color }) => (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: KT.glassDark,
backdropFilter: 'blur(20px)',
border: `1px solid ${KT.borderSoft}`,
padding: '8px 14px',
borderRadius: 999,
boxShadow: KT.shadowSm,
}}>
<span style={{ color: color || KT.text }}><Icon emoji={icon} size={16} /></span>
<span style={{
fontSize: 16, fontWeight: 800,
color: color || KT.text,
}}>{value}</span>
{label && (
<span style={{ fontSize: 12, color: KT.textMuted, fontWeight: 600 }}>
{label}
</span>
)}
</div>
);
// =================================================================
// === FloatingShapes — декоративные кубики на фоне ===
// =================================================================
const FloatingShapes = () => {
const shapes = [
{ left: '5%', top: '20%', size: 56, color: KT.accent, delay: 0, duration: 9 },
{ left: '88%', top: '15%', size: 72, color: KT.violet, delay: 1.2, duration: 11 },
{ left: '12%', top: '70%', size: 48, color: KT.pink, delay: 0.6, duration: 8 },
{ left: '82%', top: '65%', size: 64, color: KT.cyan, delay: 1.8, duration: 10 },
];
return (
<>
{shapes.map((s, i) => (
<div key={i} style={{
position: 'absolute',
left: s.left, top: s.top,
width: s.size, height: s.size,
borderRadius: 14,
background: s.color,
opacity: 0.12,
animation: `kubikonFloat ${s.duration}s ease-in-out ${s.delay}s infinite`,
pointerEvents: 'none',
zIndex: 1,
}} />
))}
</>
);
};
// =================================================================
// === Header — sticky glass ===
// =================================================================
const Header = ({ compact }) => (
<div style={{
background: KT.glass,
backdropFilter: 'blur(20px)',
borderBottom: `1px solid ${KT.borderSoft}`,
padding: '12px 24px',
position: 'sticky', top: 0, zIndex: 50,
}}>
<div style={{
maxWidth: 1200, margin: '0 auto',
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
{/* Возврат в школу — только на мобиле/планшете, где меню школы
скрыто. На десктопе слева есть левое меню сайта, отдельная
кнопка не нужна. */}
{compact && (
<Link to="/" style={{
color: KT.textSecondary, textDecoration: 'none',
fontSize: 13, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
boxShadow: KT.shadowSm,
whiteSpace: 'nowrap',
}} title="Вернуться в Майнкрафтию">
<Icon name="arrow-left" size={14} /> Майнкрафтия
</Link>
)}
<Link to="/kubikon" style={{
color: KT.text, textDecoration: 'none',
fontSize: 14, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
boxShadow: KT.shadowSm,
transition: 'all 150ms ease',
}}><Icon name="arrow-left" size={13} /> В ленту</Link>
<Link to="/kubikon" style={{
marginLeft: 'auto',
display: 'inline-flex', alignItems: 'center', gap: 10,
textDecoration: 'none',
}}>
<RublocsLogo size={32} />
<span style={{
fontSize: 22, fontWeight: 900, color: KT.text,
letterSpacing: -0.5,
}}>
Рублокс
</span>
</Link>
</div>
</div>
);
// =================================================================
// === RatingCard — крупный рейтинг с прогресс-баром и кнопками ===
// =================================================================
const RatingCard = ({ ratingPct, totalVotes, likes, dislikes, vote, onVote,
favorited, onFavorite }) => (
<div style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusXl,
padding: 22,
boxShadow: KT.shadowMd,
position: 'relative',
overflow: 'hidden',
}}>
{/* Градиентная полоска сверху */}
<div style={{
position: 'absolute', left: 0, right: 0, top: 0, height: 4,
background: KT.gradientBrand,
}} />
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 16,
}}>
<div style={{
fontSize: 56, fontWeight: 900,
color: ratingColor(ratingPct),
lineHeight: 1, letterSpacing: -2,
}}>
{ratingPct != null ? `${ratingPct}` : '—'}
{ratingPct != null && (
<span style={{
fontSize: 24, fontWeight: 800, color: KT.textMuted,
marginLeft: 4,
}}>%</span>
)}
</div>
<div style={{
fontSize: 12, color: KT.textSecondary,
marginBottom: 8, fontWeight: 600,
}}>
Рейтинг<br />
<span style={{ color: KT.textMuted, fontWeight: 500 }}>
{totalVotes} {pluralRu(totalVotes, ['голос', 'голоса', 'голосов'])}
</span>
</div>
</div>
{/* Прогресс-бар лайков/дизлайков */}
{totalVotes > 0 && (
<div style={{
height: 8, borderRadius: 999,
background: KT.bgMuted,
overflow: 'hidden',
display: 'flex',
marginBottom: 16,
}}>
<div style={{
width: `${(likes / totalVotes) * 100}%`,
background: `linear-gradient(90deg, ${KT.success}, #34d399)`,
transition: 'width 400ms ease',
}} />
<div style={{
width: `${(dislikes / totalVotes) * 100}%`,
background: `linear-gradient(90deg, #f87171, ${KT.danger})`,
transition: 'width 400ms ease',
}} />
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<VoteBtn
active={vote === 'like'}
activeBg={`linear-gradient(135deg, ${KT.success} 0%, #34d399 100%)`}
onClick={() => onVote('like')}
icon="👍"
count={likes}
/>
<VoteBtn
active={vote === 'dislike'}
activeBg={`linear-gradient(135deg, #f87171 0%, ${KT.danger} 100%)`}
onClick={() => onVote('dislike')}
icon="👎"
count={dislikes}
/>
</div>
{/* Кнопка избранного — SVG-сердечко симметричное (эмоджи кривые) */}
{onFavorite && (
<button
onClick={onFavorite}
style={{
marginTop: 12, width: '100%',
padding: '12px 16px', borderRadius: 12,
background: favorited
? 'linear-gradient(135deg, #ef4444, #ec4899)'
: KT.bgMuted,
color: favorited ? '#fff' : KT.text,
border: favorited ? 'none' : `1px solid ${KT.border}`,
fontWeight: 800, fontSize: 14,
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: favorited
? '0 8px 22px rgba(239, 68, 68, 0.4)'
: 'none',
transition: 'all 220ms cubic-bezier(0.34,1.56,0.64,1)',
}}
>
<svg width="18" height="18" 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>
{favorited ? 'В избранном' : 'В избранное'}
</button>
)}
</div>
);
const VoteBtn = ({ active, activeBg, onClick, icon, count }) => {
const [h, setH] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setH(true)}
onMouseLeave={() => setH(false)}
style={{
flex: 1, padding: '12px 14px',
background: active
? activeBg
: (h ? KT.bgHover : KT.bgPage),
color: active ? '#fff' : KT.text,
border: `1px solid ${active ? 'transparent' : KT.border}`,
borderRadius: KT.radius,
fontSize: 15, fontWeight: 800,
cursor: 'pointer', fontFamily: KT.font,
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: (h && !active) ? 'translateY(-2px)' : (active ? 'translateY(-1px)' : 'translateY(0)'),
boxShadow: active
? '0 8px 20px rgba(0,0,0,0.18)'
: (h ? KT.shadow : 'none'),
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<Icon emoji={icon} size={18} />
{count}
</button>
);
};
// =================================================================
// === MetricsGrid — компактная сетка метрик ===
// =================================================================
const MetricsGrid = ({ meta }) => (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 10,
}}>
<Stat icon="🎮" label="Плеи" value={(meta.play_count || 0).toLocaleString('ru')} accent />
<Stat icon="📅" label="Создана"
value={meta.created_at ? formatRelative(meta.created_at) : '—'} />
<Stat icon="🎯" label="Жанр" value={GENRES[meta.genre] || 'Другое'} />
<Stat icon="🔞" label="Возраст" value={`${meta.age_rating || 12}+`} />
</div>
);
const Stat = ({ icon, label, value, accent }) => (
<div style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusLg,
padding: '12px 14px',
boxShadow: KT.shadowSm,
position: 'relative',
overflow: 'hidden',
}}>
{accent && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
width: 3, background: KT.gradientBrand,
}} />
)}
<div style={{
fontSize: 10, color: KT.textMuted,
textTransform: 'uppercase', letterSpacing: 0.6, fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 4,
}}>
<Icon emoji={icon} size={12} /> {label}
</div>
<div style={{
fontSize: 15, fontWeight: 800, color: KT.text,
marginTop: 4,
// Без nowrap — на мобильной 2x2 сетке длинные значения
// (типа «Песочница», «10 минут назад») должны переноситься.
overflowWrap: 'break-word',
wordBreak: 'break-word',
lineHeight: 1.25,
}}>
{value}
</div>
</div>
);
// =================================================================
// === AuthorCard ===
// =================================================================
const AuthorCard = ({ userId, username }) => (
<Link to={`/user/${userId}`} style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusLg,
padding: 14,
boxShadow: KT.shadowSm,
display: 'flex', alignItems: 'center', gap: 12,
textDecoration: 'none',
transition: 'all 150ms ease',
}}>
<div style={{
width: 44, height: 44, borderRadius: '50%',
background: KT.gradientBrand,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 800,
flexShrink: 0,
boxShadow: KT.shadowAccent,
}}>
{(username || '?').slice(0, 1).toUpperCase()}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: 11, color: KT.textMuted,
textTransform: 'uppercase', letterSpacing: 0.5, fontWeight: 700,
}}>
Автор
</div>
<div style={{
fontSize: 15, fontWeight: 800, color: KT.text,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{username || `Игрок #${userId}`}
</div>
</div>
<span style={{ color: KT.textMuted }}><Icon name="arrow-right" size={18} /></span>
</Link>
);
// =================================================================
// === Section ===
// =================================================================
const Section = ({ title, children, gradient }) => (
<div style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusXl,
padding: 24,
boxShadow: KT.shadowMd,
position: 'relative',
overflow: 'hidden',
}}>
{gradient && (
<div style={{
position: 'absolute', left: 0, right: 0, top: 0, height: 4,
background: gradient,
}} />
)}
<h2 style={{
margin: '0 0 16px',
fontSize: 20, fontWeight: 800, color: KT.text,
letterSpacing: -0.3,
}}>
{title}
</h2>
{children}
</div>
);
// =================================================================
// === SkeletonHero ===
// =================================================================
const SkeletonHero = () => (
<>
<div style={{
position: 'relative', minHeight: 540,
background: 'linear-gradient(135deg, #e0e8ff 0%, #f5f7fb 100%)',
paddingTop: 24,
}}>
<div style={{
maxWidth: 1200, margin: '0 auto', padding: 24,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.2fr) minmax(0, 1fr)',
gap: 40,
}}>
<div style={skeletonStyle({ aspectRatio: '16/10', borderRadius: 28 })} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, justifyContent: 'center' }}>
<div style={skeletonStyle({ height: 28, width: '40%', borderRadius: 999 })} />
<div style={skeletonStyle({ height: 56, width: '90%' })} />
<div style={skeletonStyle({ height: 36, width: '60%', borderRadius: 999 })} />
<div style={skeletonStyle({ height: 60, width: '70%' })} />
</div>
</div>
</div>
<div style={{
maxWidth: 1200, margin: '-80px auto 0',
padding: '0 24px 64px',
display: 'grid',
gridTemplateColumns: 'minmax(0, 2fr) minmax(0, 1fr)',
gap: 24,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={skeletonStyle({ height: 160, borderRadius: 20 })} />
<div style={skeletonStyle({ height: 240, borderRadius: 20 })} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={skeletonStyle({ height: 200, borderRadius: 20 })} />
<div style={skeletonStyle({ height: 140, borderRadius: 14 })} />
</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;
}
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];
}
const pageStyle = {
minHeight: '100vh',
background: KT.bg,
color: KT.text,
fontFamily: KT.font,
};
// =================================================================
// === GameComments — секция комментариев на странице игры ===
// =================================================================
const GameComments = ({ projectId, projectOwnerId, currentUserId, onRequestAuth }) => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const username = (() => {
try {
const t = localStorage.getItem('Authorization');
if (!t) return '';
const p = jwtDecode(t.startsWith('Bearer ') ? t.slice(7) : t);
return p?.firstName || p?.email || '';
} catch (e) { return ''; }
})();
const load = useCallback(async () => {
setLoading(true);
try {
const res = await Kubikon3DApi.getProjectComments(projectId);
setItems(res.data?.comments || []);
} catch (e) { /* ignore */ }
setLoading(false);
}, [projectId]);
useEffect(() => { load(); }, [load]);
const send = async () => {
if (!currentUserId) { onRequestAuth?.(); return; }
const t = text.trim();
if (!t) { setError('Напиши хотя бы что-нибудь.'); return; }
setSubmitting(true);
setError(null);
try {
const res = await Kubikon3DApi.createProjectComment(projectId, {
user_id: currentUserId, username, text: t,
});
const newC = res.data?.comment;
if (newC) setItems(prev => [newC, ...prev]);
setText('');
} catch (e) {
const code = e?.response?.data?.error;
const msg = e?.response?.data?.message;
if (code === 'too_frequent' || code === 'rate_limit') {
setError(msg || 'Слишком часто. Подожди пару секунд.');
} else if (code === 'login_required') {
onRequestAuth?.();
} else if (code === 'blocked_text') {
// Мат / грубость — комментарий отклонён фильтром.
setError(msg || 'В комментарии есть запрещённые слова.');
} else {
setError(msg || code || 'Ошибка отправки');
}
}
setSubmitting(false);
};
const removeMine = async (cid) => {
try {
await Kubikon3DApi.deleteProjectComment(cid, currentUserId);
setItems(prev => prev.filter(x => x.id !== cid));
} catch (e) { /* ignore */ }
};
return (
<Section
gradient={KT.gradientPurple}
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<span><Icon name="message" size={13} /> Комментарии</span>
<span style={{
fontSize: 13, color: '#fff',
fontWeight: 800,
background: KT.gradientBrand,
padding: '2px 10px',
borderRadius: 999,
boxShadow: KT.shadowAccent,
}}>
{items.length}
</span>
</span>
}
>
{currentUserId ? (
<CommentForm
text={text}
onChange={setText}
submitting={submitting}
error={error}
onSend={send}
/>
) : (
<div style={{
background: KT.accentSoft,
border: `1px solid ${KT.accent}33`,
borderRadius: KT.radiusLg,
padding: '16px 18px', marginBottom: 18,
fontSize: 14, color: KT.accentDeep,
textAlign: 'center', fontWeight: 600,
}}>
<Link to="/login" style={{
color: KT.accent, textDecoration: 'underline', fontWeight: 800,
}}>Войди в аккаунт</Link>
{' '} и оставь комментарий
</div>
)}
{loading ? (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
}}>
{[1, 2, 3].map(i => (
<div key={i} style={skeletonStyle({ height: 80, borderRadius: 14 })} />
))}
</div>
) : items.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center',
color: KT.textSecondary,
background: KT.bgSubtle,
borderRadius: KT.radiusLg,
border: `1px dashed ${KT.borderStrong}`,
}}>
<div style={{
marginBottom: 8,
animation: 'kubikonFloat 3s ease-in-out infinite',
}}><Icon emoji="💭" size={48} /></div>
<div style={{ fontWeight: 700, color: KT.text, marginBottom: 4 }}>
Пока нет комментариев
</div>
<div style={{ fontSize: 13 }}>Будь первым!</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{items.map((c, i) => {
const isMine = currentUserId && c.user_id === currentUserId;
const canDelete = isMine || currentUserId === projectOwnerId;
return (
<CommentRow
key={c.id}
comment={c}
canDelete={canDelete}
isOwner={!isMine && canDelete}
onDelete={() => removeMine(c.id)}
animateDelay={i * 25}
/>
);
})}
</div>
)}
</Section>
);
};
const CommentForm = ({ text, onChange, submitting, error, onSend }) => {
const [hovered, setHovered] = useState(false);
const [focus, setFocus] = useState(false);
return (
<div style={{ marginBottom: 20 }}>
<textarea
value={text}
onChange={(e) => onChange(e.target.value.slice(0, 1000))}
placeholder="Что думаешь об игре? Поделись впечатлением…"
rows={3}
disabled={submitting}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
width: '100%', boxSizing: 'border-box',
background: KT.bgSubtle,
border: `1.5px solid ${focus ? KT.accent : KT.border}`,
borderRadius: KT.radiusLg,
padding: '14px 16px',
color: KT.text,
fontSize: 14, fontFamily: KT.font,
resize: 'vertical',
outline: 'none',
boxShadow: focus ? KT.shadowGlow : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
}}
/>
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginTop: 10,
}}>
<span style={{
fontSize: 12, color: KT.textMuted, fontWeight: 600,
}}>
{text.length}/1000
</span>
{error && (
<span style={{
fontSize: 12, color: KT.danger, flex: 1, fontWeight: 600,
}}>
<Icon name="warning" size={13} /> {error}
</span>
)}
<button
onClick={onSend}
disabled={submitting || !text.trim()}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
...buttonPrimary(hovered && !submitting && text.trim()),
marginLeft: 'auto',
opacity: (submitting || !text.trim()) ? 0.5 : 1,
cursor: (submitting || !text.trim()) ? 'not-allowed' : 'pointer',
}}
>
{submitting ? 'Отправка…' : <><Icon name="send" size={14} /> Отправить</>}
</button>
</div>
</div>
);
};
const CommentRow = ({ comment, canDelete, isOwner, onDelete, animateDelay }) => (
<div style={{
background: KT.bgSubtle,
border: `1px solid ${KT.borderSoft}`,
borderRadius: KT.radiusLg,
padding: '14px 16px',
animation: `kubikonFadeIn 320ms ease ${animateDelay}ms both`,
transition: 'all 150ms ease',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
marginBottom: 8, flexWrap: 'wrap',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: KT.gradientBrand,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 800,
flexShrink: 0,
}}>
{(comment.username || '?').slice(0, 1).toUpperCase()}
</div>
<Link to={`/user/${comment.user_id}`} style={{
fontSize: 14, fontWeight: 800,
color: KT.text, textDecoration: 'none',
}}>
{comment.username || `#${comment.user_id}`}
</Link>
<span style={{ fontSize: 11, color: KT.textMuted, fontWeight: 600 }}>
{formatRelative(comment.created_at)}
</span>
{comment.edited_at && (
<span style={{
fontSize: 11, color: KT.textMuted, fontStyle: 'italic',
}}>(изменено)</span>
)}
{canDelete && (
<button
onClick={onDelete}
title={isOwner ? 'Удалить как автор игры' : 'Удалить'}
style={{
marginLeft: 'auto',
background: 'transparent',
border: `1px solid ${KT.border}`,
color: KT.textMuted,
borderRadius: 6,
padding: '3px 10px',
fontSize: 11,
cursor: 'pointer',
fontFamily: KT.font,
transition: 'all 150ms ease',
}}
><Icon name="delete" size={14} /></button>
)}
</div>
<div style={{
fontSize: 14, color: KT.text,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
lineHeight: 1.55,
paddingLeft: 38,
}}>
{comment.text}
</div>
</div>
);
export default KubikonGamePage;