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 ? (
) : (
)}
{/* 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}`}
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',
}}
>
Играть сейчас
{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 && (
{favorited ? 'В избранном' : 'В избранное'}
)}
);
const VoteBtn = ({ active, activeBg, onClick, icon, count }) => {
const [h, setH] = useState(false);
return (
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,
}}
>
{count}
);
};
// =================================================================
// === 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 (
);
};
const CommentRow = ({ comment, canDelete, isOwner, onDelete, animateDelay }) => (
{(comment.username || '?').slice(0, 1).toUpperCase()}
{comment.username || `#${comment.user_id}`}
{formatRelative(comment.created_at)}
{comment.edited_at && (
(изменено)
)}
{canDelete && (
)}
{comment.text}
);
export default KubikonGamePage;