studio/src/community/KubikonUserGames.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

363 lines
15 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 } 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;