Большой консолидирующий коммит после поднятия 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>
363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||
import { KT, KUBIKON_KEYFRAMES, skeletonStyle } from '../utils/kubikonTheme';
|
||
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
||
import Icon from '../editor/Icon';
|
||
|
||
/**
|
||
* KubikonUserGames — публичный профиль автора со списком его опубликованных игр.
|
||
* URL: /user/:userId
|
||
*
|
||
* Видны опубликованные игры автора (status='published').
|
||
* Дизайн в едином wow-стиле Рублокса.
|
||
*/
|
||
const KubikonUserGames = () => {
|
||
const { userId } = useParams();
|
||
const navigate = useNavigate();
|
||
const [items, setItems] = useState([]);
|
||
const [authorName, setAuthorName] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
(async () => {
|
||
try {
|
||
const res = await Kubikon3DApi.getUserGames(userId);
|
||
if (active) {
|
||
setItems(res.data?.projects || []);
|
||
setAuthorName(res.data?.author_username || '');
|
||
setLoading(false);
|
||
}
|
||
} catch (e) {
|
||
if (active) setLoading(false);
|
||
}
|
||
})();
|
||
return () => { active = false; };
|
||
}, [userId]);
|
||
|
||
const totalPlays = items.reduce((s, p) => s + (p.play_count || 0), 0);
|
||
const totalLikes = items.reduce((s, p) => s + (p.likes_count || 0), 0);
|
||
const initial = (authorName || '?').slice(0, 1).toUpperCase();
|
||
|
||
return (
|
||
<div style={{
|
||
minHeight: '100vh',
|
||
background: KT.bg,
|
||
color: KT.text,
|
||
fontFamily: KT.font,
|
||
}}>
|
||
<style>{KUBIKON_KEYFRAMES}</style>
|
||
|
||
{/* Sticky glass header */}
|
||
<div style={{
|
||
background: KT.glassDark,
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
borderBottom: `1px solid ${KT.borderSoft}`,
|
||
padding: '12px 24px',
|
||
position: 'sticky', top: 0, zIndex: 50,
|
||
}}>
|
||
<div style={{
|
||
maxWidth: 1240, margin: '0 auto',
|
||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||
}}>
|
||
<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,
|
||
}}><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>
|
||
|
||
{/* Hero — большая аватарка + имя + статистика */}
|
||
<div style={{
|
||
position: 'relative',
|
||
background: KT.gradientHero,
|
||
backgroundSize: '200% 200%',
|
||
animation: 'kubikonGradientShift 16s ease-in-out infinite',
|
||
padding: '56px 24px 80px',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Floating shapes */}
|
||
<FloatingShapes />
|
||
|
||
<div style={{
|
||
position: 'relative', zIndex: 2,
|
||
maxWidth: 1100, margin: '0 auto',
|
||
display: 'flex', alignItems: 'center',
|
||
gap: 28, flexWrap: 'wrap',
|
||
}}>
|
||
{/* Avatar */}
|
||
<div style={{
|
||
width: 120, height: 120, borderRadius: '50%',
|
||
background: '#fff',
|
||
color: KT.accent,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 48, fontWeight: 900,
|
||
flexShrink: 0,
|
||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.28), 0 0 0 6px rgba(255,255,255,0.18)',
|
||
animation: 'kubikonFloat 5s ease-in-out infinite',
|
||
}}>
|
||
{initial}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minWidth: 240 }}>
|
||
<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
background: 'rgba(255,255,255,0.18)',
|
||
backdropFilter: 'blur(8px)',
|
||
border: '1px solid rgba(255,255,255,0.30)',
|
||
padding: '4px 12px',
|
||
borderRadius: 999,
|
||
fontSize: 11, fontWeight: 800,
|
||
color: '#fff', letterSpacing: 1.2,
|
||
textTransform: 'uppercase',
|
||
marginBottom: 12,
|
||
}}>
|
||
<Icon emoji="👤" size={13} /> Профиль автора
|
||
</div>
|
||
<h1 style={{
|
||
margin: 0,
|
||
fontSize: 'clamp(28px, 5vw, 44px)',
|
||
fontWeight: 900,
|
||
color: '#fff',
|
||
letterSpacing: -1,
|
||
textShadow: '0 2px 16px rgba(0,0,0,0.18)',
|
||
wordBreak: 'break-word',
|
||
}}>
|
||
{authorName || `Игрок #${userId}`}
|
||
</h1>
|
||
|
||
{/* Статистика */}
|
||
<div style={{
|
||
display: 'flex', gap: 12, marginTop: 16, flexWrap: 'wrap',
|
||
}}>
|
||
<StatPill icon="🎮" value={items.length} label={
|
||
items.length === 1 ? 'игра' : (items.length >= 2 && items.length <= 4) ? 'игры' : 'игр'
|
||
} />
|
||
<StatPill icon="▶️" value={totalPlays.toLocaleString('ru')} label="плеев" />
|
||
<StatPill icon="❤️" value={totalLikes.toLocaleString('ru')} label="лайков" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список игр — overlap с hero */}
|
||
<div style={{
|
||
maxWidth: 1240, margin: '-50px auto 0',
|
||
padding: '0 24px 64px',
|
||
position: 'relative', zIndex: 5,
|
||
animation: 'kubikonHeroSlide 480ms cubic-bezier(0.2, 0.8, 0.4, 1) both',
|
||
animationDelay: '120ms',
|
||
}}>
|
||
{loading ? (
|
||
<SkeletonGrid />
|
||
) : items.length === 0 ? (
|
||
<div style={{
|
||
padding: 56, textAlign: 'center',
|
||
background: KT.bgPage,
|
||
border: `1px dashed ${KT.borderStrong}`,
|
||
borderRadius: KT.radiusXl,
|
||
color: KT.textSecondary,
|
||
animation: 'kubikonFadeInScale 420ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
}}>
|
||
<div style={{
|
||
fontSize: 64, marginBottom: 12,
|
||
animation: 'kubikonFloat 4s ease-in-out infinite',
|
||
display: 'inline-block',
|
||
}}><Icon name="sprout" size={14} /></div>
|
||
<div style={{
|
||
fontSize: 20, fontWeight: 800, color: KT.text, marginBottom: 6,
|
||
}}>
|
||
Пока нет опубликованных игр
|
||
</div>
|
||
<div style={{ fontSize: 14 }}>
|
||
У этого автора скоро здесь что-то появится
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||
gap: 18,
|
||
}}>
|
||
{items.map((p, i) => (
|
||
<GameCard
|
||
key={p.id}
|
||
project={p}
|
||
onClick={() => navigate(`/game/${p.id}`)}
|
||
animateDelay={Math.min(i * 35, 600)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const StatPill = ({ icon, value, label }) => (
|
||
<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||
background: 'rgba(255,255,255,0.18)',
|
||
backdropFilter: 'blur(12px)',
|
||
border: '1px solid rgba(255,255,255,0.25)',
|
||
padding: '8px 16px',
|
||
borderRadius: 999,
|
||
color: '#fff',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.10)',
|
||
}}>
|
||
<Icon emoji={icon} size={16} />
|
||
<span style={{ fontSize: 16, fontWeight: 800 }}>{value}</span>
|
||
<span style={{ fontSize: 12, opacity: 0.86, fontWeight: 600 }}>{label}</span>
|
||
</div>
|
||
);
|
||
|
||
const FloatingShapes = () => (
|
||
<>
|
||
<div style={{
|
||
position: 'absolute', top: '20%', left: '8%',
|
||
width: 64, height: 64,
|
||
background: 'rgba(255, 255, 255, 0.12)',
|
||
borderRadius: 14,
|
||
transform: 'rotate(15deg)',
|
||
animation: 'kubikonFloat 8s ease-in-out infinite',
|
||
}} />
|
||
<div style={{
|
||
position: 'absolute', top: '60%', right: '12%',
|
||
width: 80, height: 80,
|
||
background: 'rgba(255, 255, 255, 0.10)',
|
||
borderRadius: 18,
|
||
transform: 'rotate(-10deg)',
|
||
animation: 'kubikonFloat 10s ease-in-out 1s infinite',
|
||
}} />
|
||
<div style={{
|
||
position: 'absolute', top: '30%', right: '32%',
|
||
width: 40, height: 40,
|
||
background: 'rgba(255, 255, 255, 0.14)',
|
||
borderRadius: 10,
|
||
transform: 'rotate(28deg)',
|
||
animation: 'kubikonFloat 7s ease-in-out 2s infinite',
|
||
}} />
|
||
</>
|
||
);
|
||
|
||
const GameCard = ({ project, onClick, animateDelay = 0 }) => {
|
||
const [hovered, setHovered] = useState(false);
|
||
const p = project;
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={onClick}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
style={{
|
||
background: KT.bgPage,
|
||
border: `1px solid ${hovered ? KT.accent : KT.border}`,
|
||
borderRadius: KT.radiusLg,
|
||
boxShadow: hovered ? KT.shadowLg : KT.shadow,
|
||
transform: hovered ? 'translateY(-6px)' : 'translateY(0)',
|
||
transition: 'all 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
cursor: 'pointer',
|
||
overflow: 'hidden',
|
||
animation: `kubikonFadeIn 460ms cubic-bezier(0.34, 1.56, 0.64, 1) ${animateDelay}ms both`,
|
||
}}
|
||
>
|
||
<div style={{
|
||
aspectRatio: '4/3',
|
||
background: KT.gradientBrand,
|
||
position: 'relative', overflow: 'hidden',
|
||
}}>
|
||
{p.thumbnail ? (
|
||
<img src={p.thumbnail} alt={p.title}
|
||
style={{
|
||
width: '100%', height: '100%', objectFit: 'cover',
|
||
transform: hovered ? 'scale(1.08)' : 'scale(1)',
|
||
transition: 'transform 600ms cubic-bezier(0.2, 0.8, 0.4, 1)',
|
||
}} />
|
||
) : (
|
||
<div style={{
|
||
position: 'absolute', inset: 0,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 64, color: '#fff',
|
||
}}><Icon name="gamepad" size={14} /></div>
|
||
)}
|
||
{/* Умная лента: бейджа «ранг» больше нет — все опубликованные
|
||
игры равны, их позицию определяет алгоритм в ленте. */}
|
||
<span style={{
|
||
position: 'absolute', top: 10, right: 10,
|
||
background: 'rgba(15, 23, 42, 0.85)',
|
||
color: '#fff',
|
||
fontSize: 11, padding: '3px 9px',
|
||
borderRadius: 999, fontWeight: 700,
|
||
backdropFilter: 'blur(8px)',
|
||
}}>{p.age_rating || 12}+</span>
|
||
</div>
|
||
|
||
<div style={{ padding: '12px 14px' }}>
|
||
<div style={{
|
||
fontSize: 15, fontWeight: 800,
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
color: KT.text, marginBottom: 4,
|
||
letterSpacing: -0.2,
|
||
}}>{p.title}</div>
|
||
<div style={{
|
||
fontSize: 12, color: KT.textMuted,
|
||
fontWeight: 600,
|
||
display: 'flex', gap: 12,
|
||
}}>
|
||
<span><Icon name="gamepad" size={13} /> {(p.play_count || 0).toLocaleString('ru')}</span>
|
||
<span><Icon name="heart" size={13} /> {(p.likes_count || 0).toLocaleString('ru')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SkeletonGrid = () => (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||
gap: 18,
|
||
}}>
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} style={{
|
||
background: KT.bgPage,
|
||
border: `1px solid ${KT.border}`,
|
||
borderRadius: KT.radiusLg,
|
||
overflow: 'hidden',
|
||
boxShadow: KT.shadow,
|
||
animation: `kubikonFadeIn 400ms ease ${i * 60}ms both`,
|
||
}}>
|
||
<div style={skeletonStyle({ aspectRatio: '4/3', borderRadius: 0 })} />
|
||
<div style={{ padding: '12px 14px' }}>
|
||
<div style={skeletonStyle({ height: 16, width: '70%', marginBottom: 8 })} />
|
||
<div style={skeletonStyle({ height: 12, width: '40%' })} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
export default KubikonUserGames;
|