Большой консолидирующий коммит после поднятия 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>
1293 lines
54 KiB
JavaScript
1293 lines
54 KiB
JavaScript
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;
|