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 (
); } if (error || !meta) { return (
Не удалось загрузить
{error || 'Игра не найдена'}
В ленту
); } return (
{/* === CINEMATIC HERO с blur-фоном === */} navigate(`/play/${projectId}`)} /> {/* === Контент-секция === */}
{/* На мобиле — порядок другой: сначала рейтинг (важно видеть до решения играть/нет), потом описание, метрики, автор, комментарии. */} {compact ? ( <>
{meta.description?.trim() ? (
{meta.description}
) : (
Автор не оставил описания
)}
navigate('/login')} /> ) : ( <> {/* Left: описание + комментарии */}
{meta.description?.trim() ? (
{meta.description}
) : (
Автор не оставил описания
)}
navigate('/login')} />
{/* Right: side-панель с рейтингом и метриками */}
)}
); }; // ================================================================= // === 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 (
{/* Blur background из thumbnail */}
{/* Если нет thumbnail — градиентный фон */} {!meta.thumbnail && (
)} {/* Затемнение и vignette */}
{/* Floating shapes — только на десктопе */} {!compact && }
{/* Foreground sharp poster */}
{meta.thumbnail ? ( {meta.title} ) : (
)} {/* Play overlay при hover */}
{ratingPct != null && (
{ratingPct}% • {totalVotes} {pluralRu(totalVotes, ['голос', 'голоса', 'голосов'])}
)}
{/* Большой play в центре при hover */}
{/* Right: title + play */}
{GENRES[meta.genre] || 'Игра'}

{meta.title}

{(meta.author_username || '?').slice(0, 1).toUpperCase()} {meta.author_username || `Игрок #${meta.user_id}`}
{ratingPct != null && ( )}
); }; const HeroStatPill = ({ icon, value, label, color }) => (
{value} {label && ( {label} )}
); // ================================================================= // === 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) => (
))} ); }; // ================================================================= // === Header — sticky glass === // ================================================================= const Header = ({ compact }) => (
{/* Возврат в школу — только на мобиле/планшете, где меню школы скрыто. На десктопе слева есть левое меню сайта, отдельная кнопка не нужна. */} {compact && ( Майнкрафтия )} В ленту Рублокс
); // ================================================================= // === RatingCard — крупный рейтинг с прогресс-баром и кнопками === // ================================================================= const RatingCard = ({ ratingPct, totalVotes, likes, dislikes, vote, onVote, favorited, onFavorite }) => (
{/* Градиентная полоска сверху */}
{ratingPct != null ? `${ratingPct}` : '—'} {ratingPct != null && ( % )}
Рейтинг
{totalVotes} {pluralRu(totalVotes, ['голос', 'голоса', 'голосов'])}
{/* Прогресс-бар лайков/дизлайков */} {totalVotes > 0 && (
)}
onVote('like')} icon="👍" count={likes} /> onVote('dislike')} icon="👎" count={dislikes} />
{/* Кнопка избранного — SVG-сердечко симметричное (эмоджи кривые) */} {onFavorite && ( )}
); const VoteBtn = ({ active, activeBg, onClick, icon, count }) => { const [h, setH] = useState(false); return ( ); }; // ================================================================= // === MetricsGrid — компактная сетка метрик === // ================================================================= const MetricsGrid = ({ meta }) => (
); const Stat = ({ icon, label, value, accent }) => (
{accent && (
)}
{label}
{value}
); // ================================================================= // === AuthorCard === // ================================================================= const AuthorCard = ({ userId, username }) => (
{(username || '?').slice(0, 1).toUpperCase()}
Автор
{username || `Игрок #${userId}`}
); // ================================================================= // === Section === // ================================================================= const Section = ({ title, children, gradient }) => (
{gradient && (
)}

{title}

{children}
); // ================================================================= // === SkeletonHero === // ================================================================= const SkeletonHero = () => ( <>
); 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 (
Комментарии {items.length} } > {currentUserId ? ( ) : (
Войди в аккаунт {' '}— и оставь комментарий
)} {loading ? (
{[1, 2, 3].map(i => (
))}
) : items.length === 0 ? (
Пока нет комментариев
Будь первым!
) : (
{items.map((c, i) => { const isMine = currentUserId && c.user_id === currentUserId; const canDelete = isMine || currentUserId === projectOwnerId; return ( removeMine(c.id)} animateDelay={i * 25} /> ); })}
)}
); }; const CommentForm = ({ text, onChange, submitting, error, onSend }) => { const [hovered, setHovered] = useState(false); const [focus, setFocus] = useState(false); return (