Большой консолидирующий коммит после поднятия 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>
564 lines
20 KiB
JavaScript
564 lines
20 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import cl from './KubikonStudio.module.css';
|
||
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
||
import useDeviceType from '../hooks/useDeviceType';
|
||
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
|
||
import Icon from '../editor/Icon';
|
||
import DocIcon from './docsIcons';
|
||
import { ARTICLES, getArticle } from './learnArticles';
|
||
|
||
/**
|
||
* KubikonLearn — раздел «Изучай Студию» Рублокс Студии.
|
||
* URL: /learn — список статей
|
||
* /learn/<id> — конкретная статья
|
||
*
|
||
* Статьи (контент) — в learnArticles.jsx. Превью карточек —
|
||
* public/assets/kubikon-learn/<cover> (скриншоты редактора).
|
||
* Стиль/layout повторяют KubikonRules.jsx — своя боковая панель Studio.
|
||
*/
|
||
|
||
const KubikonLearn = () => {
|
||
const navigate = useNavigate();
|
||
const { articleId } = useParams();
|
||
const { isDesktop } = useDeviceType();
|
||
const mainRef = useRef(null);
|
||
|
||
const article = articleId ? getArticle(articleId) : null;
|
||
|
||
// при открытии статьи / возврате к списку — скролл вверх
|
||
useEffect(() => {
|
||
if (mainRef.current) mainRef.current.scrollTo({ top: 0 });
|
||
}, [articleId]);
|
||
|
||
if (!isDesktop) {
|
||
return <KubikonDesktopOnlyStub feature="Изучай Студию Рублокс" />;
|
||
}
|
||
|
||
return (
|
||
<div className={cl.studio}>
|
||
<style>{INLINE_STYLES}</style>
|
||
|
||
{/* === Левая боковая панель Studio === */}
|
||
<aside className={cl.sidebar}>
|
||
<div className={cl.sidebarHeader}>
|
||
<div className={cl.sidebarLogo}>
|
||
<RublocsLogo size={44} bg="#16181d" />
|
||
</div>
|
||
<div className={cl.sidebarTitle}>
|
||
<div className={cl.brandName}>Рублокс</div>
|
||
<div className={cl.brandSub}>Студия</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav className={cl.sidebarNav}>
|
||
<button
|
||
className={cl.navNewGame}
|
||
onClick={() => navigate('/edit/new')}
|
||
>
|
||
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
|
||
<span>Новая игра</span>
|
||
</button>
|
||
<button className={cl.navItem} onClick={() => navigate('/?tab=recent')}>
|
||
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
|
||
<span>История</span>
|
||
</button>
|
||
<button className={cl.navItem} onClick={() => navigate('/?tab=home')}>
|
||
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
|
||
<span>Главная</span>
|
||
</button>
|
||
<button className={cl.navItem} onClick={() => navigate('/?tab=my')}>
|
||
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
|
||
<span>Мои игры</span>
|
||
</button>
|
||
<button className={cl.navItem} onClick={() => navigate('/?tab=templates')}>
|
||
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
|
||
<span>Шаблоны</span>
|
||
</button>
|
||
<button className={cl.navItem} onClick={() => navigate('/?tab=archive')}>
|
||
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
|
||
<span>Архив</span>
|
||
</button>
|
||
<button className={cl.navItem} onClick={() => navigate('/docs')}>
|
||
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
|
||
<span>ВИКИ</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<div className={cl.sidebarFooter}>
|
||
<button
|
||
className={cl.docsBtn}
|
||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
||
onClick={() => navigate('/rules')}
|
||
>
|
||
<Icon name="shield" size={15} /> Правила создания игр
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* === Основной контент === */}
|
||
<main className={cl.main} ref={mainRef}>
|
||
<header className={cl.topBar}>
|
||
<div>
|
||
<h1 className={cl.pageTitle}>
|
||
<Icon name="book-open" size={13} />{' '}
|
||
{article ? article.title : 'Изучай Студию'}
|
||
</h1>
|
||
<p className={cl.pageSub}>
|
||
{article
|
||
? 'Статья — Изучай Студию'
|
||
: 'Уроки и материалы, чтобы быстро освоиться в Рублокс Студии'}
|
||
</p>
|
||
</div>
|
||
<div className={cl.topBarActions}>
|
||
{article ? (
|
||
<button className={cl.searchBox} onClick={() => navigate('/learn')}>
|
||
← Ко всем статьям
|
||
</button>
|
||
) : (
|
||
<button className={cl.searchBox} onClick={() => navigate('/')}>
|
||
← В Studio
|
||
</button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── СТРАНИЦА СТАТЬИ ── */}
|
||
{article && (
|
||
<article className="lrnArticle">
|
||
<div
|
||
className="lrnArticleHero"
|
||
style={{ background: `linear-gradient(135deg, ${article.color} 0%, #15151c 130%)` }}
|
||
>
|
||
<div className="lrnArticleHero__ico">
|
||
<DocIcon name={article.icon} size={56} />
|
||
</div>
|
||
<div>
|
||
<div className="lrnArticleHero__badge">Изучай Студию</div>
|
||
<h2 className="lrnArticleHero__title">{article.title}</h2>
|
||
<p className="lrnArticleHero__sum">{article.summary}</p>
|
||
</div>
|
||
</div>
|
||
<div className="lrnArticleBody">{article.body}</div>
|
||
|
||
{/* навигация: следующая статья */}
|
||
<ArticleFooter currentId={article.id} navigate={navigate} />
|
||
</article>
|
||
)}
|
||
|
||
{/* ── СПИСОК СТАТЕЙ ── */}
|
||
{!article && (
|
||
<>
|
||
<section className="lrnHero">
|
||
<div className="lrnHero__content">
|
||
<div className="lrnHero__badge">
|
||
<DocIcon name="rocket" size={13} /> Изучай Студию
|
||
</div>
|
||
<h2 className="lrnHero__title">Освойся в Рублокс Студии</h2>
|
||
<p className="lrnHero__desc">
|
||
Короткие статьи о главном: как загружать свои
|
||
модели, чем отличаются плееры, как писать
|
||
скрипты и публиковать игры. Начни с любой.
|
||
</p>
|
||
</div>
|
||
<div className="lrnHero__emoji"><DocIcon name="book" size={84} /></div>
|
||
</section>
|
||
|
||
<div className="lrnGrid">
|
||
{ARTICLES.map((a) => (
|
||
<button
|
||
key={a.id}
|
||
className="lrnCard"
|
||
onClick={() => navigate('/learn/' + a.id)}
|
||
>
|
||
<div className="lrnCard__cover">
|
||
{/* запасной фон ПОД картинкой (если скрин
|
||
не загрузился — он останется виден) */}
|
||
<div
|
||
className="lrnCard__fallback"
|
||
style={{ background: `linear-gradient(135deg, ${a.color} 0%, #15151c 130%)` }}
|
||
>
|
||
<DocIcon name={a.icon} size={42} />
|
||
</div>
|
||
<img
|
||
className="lrnCard__img"
|
||
src={`/assets/kubikon-learn/${a.cover}`}
|
||
alt={a.title}
|
||
loading="lazy"
|
||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||
/>
|
||
<span className="lrnCard__chip" style={{ background: a.color }}>
|
||
<DocIcon name={a.icon} size={15} />
|
||
</span>
|
||
</div>
|
||
<div className="lrnCard__body">
|
||
<div className="lrnCard__title">{a.title}</div>
|
||
<div className="lrnCard__sum">{a.summary}</div>
|
||
<div className="lrnCard__more">Читать →</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Подвал статьи — кнопка к следующей статье по кругу.
|
||
const ArticleFooter = ({ currentId, navigate }) => {
|
||
const idx = ARTICLES.findIndex((a) => a.id === currentId);
|
||
const next = ARTICLES[(idx + 1) % ARTICLES.length];
|
||
return (
|
||
<div className="lrnNext">
|
||
<div className="lrnNext__label">Следующая статья</div>
|
||
<button
|
||
className="lrnNext__btn"
|
||
onClick={() => navigate('/learn/' + next.id)}
|
||
>
|
||
<span className="lrnNext__ico"><DocIcon name={next.icon} size={20} /></span>
|
||
<span>{next.title}</span>
|
||
<span className="lrnNext__arrow">→</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════
|
||
// Инлайн-стили
|
||
// ══════════════════════════════════════════════════════════════════
|
||
const INLINE_STYLES = `
|
||
/* ── Hero списка ── */
|
||
.lrnHero {
|
||
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 55%, #6d28d9 100%);
|
||
background-size: 200% 200%;
|
||
animation: lrnGrad 16s ease-in-out infinite;
|
||
border-radius: 28px;
|
||
padding: 36px 40px;
|
||
margin-bottom: 28px;
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 24px;
|
||
box-shadow: 0 24px 56px rgba(51, 87, 255, 0.28);
|
||
}
|
||
@keyframes lrnGrad {
|
||
0%, 100% { background-position: 0% 50%; }
|
||
50% { background-position: 100% 50%; }
|
||
}
|
||
.lrnHero__content { flex: 1; min-width: 0; }
|
||
.lrnHero__badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border: 1px solid rgba(255, 255, 255, 0.30);
|
||
color: #fff;
|
||
padding: 5px 14px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 1.4px;
|
||
text-transform: uppercase;
|
||
margin-bottom: 12px;
|
||
}
|
||
.lrnHero__title {
|
||
margin: 0 0 10px;
|
||
font-size: 32px;
|
||
font-weight: 900;
|
||
color: #fff;
|
||
letter-spacing: -1px;
|
||
line-height: 1.1;
|
||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||
}
|
||
.lrnHero__desc {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
color: rgba(255, 255, 255, 0.94);
|
||
line-height: 1.55;
|
||
max-width: 660px;
|
||
}
|
||
.lrnHero__emoji {
|
||
flex-shrink: 0;
|
||
color: #fff;
|
||
opacity: 0.9;
|
||
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.2));
|
||
}
|
||
|
||
/* ── Сетка карточек статей ── */
|
||
.lrnGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 18px;
|
||
}
|
||
/* Карточки — светлые, в стиле вики (белый фон, тёмный текст). */
|
||
.lrnCard {
|
||
background: #ffffff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
font-family: inherit;
|
||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.lrnCard:hover {
|
||
transform: translateY(-4px);
|
||
border-color: #3357ff;
|
||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
|
||
}
|
||
.lrnCard__cover {
|
||
position: relative;
|
||
aspect-ratio: 16 / 9;
|
||
overflow: hidden;
|
||
}
|
||
.lrnCard__fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
.lrnCard__img {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
transition: transform 300ms ease;
|
||
}
|
||
.lrnCard:hover .lrnCard__img { transform: scale(1.05); }
|
||
.lrnCard__chip {
|
||
position: absolute;
|
||
top: 9px; left: 9px;
|
||
width: 30px; height: 30px;
|
||
border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: #fff;
|
||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.lrnCard__body {
|
||
padding: 14px 16px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
}
|
||
.lrnCard__title {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
color: #0f172a;
|
||
margin-bottom: 6px;
|
||
letter-spacing: -0.2px;
|
||
}
|
||
.lrnCard__sum {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
line-height: 1.5;
|
||
flex: 1;
|
||
margin-bottom: 12px;
|
||
}
|
||
.lrnCard__more {
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
color: #3357ff;
|
||
}
|
||
|
||
/* ── Страница статьи ── */
|
||
.lrnArticle { display: flex; flex-direction: column; gap: 16px; }
|
||
.lrnArticleHero {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 22px;
|
||
border-radius: 22px;
|
||
padding: 28px 32px;
|
||
color: #fff;
|
||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.lrnArticleHero__ico {
|
||
flex-shrink: 0;
|
||
width: 88px; height: 88px;
|
||
border-radius: 20px;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.lrnArticleHero__badge {
|
||
display: inline-block;
|
||
background: rgba(255, 255, 255, 0.20);
|
||
border: 1px solid rgba(255, 255, 255, 0.30);
|
||
color: #fff;
|
||
padding: 4px 12px;
|
||
border-radius: 999px;
|
||
font-size: 10.5px;
|
||
font-weight: 800;
|
||
letter-spacing: 1.3px;
|
||
text-transform: uppercase;
|
||
margin-bottom: 10px;
|
||
}
|
||
.lrnArticleHero__title {
|
||
margin: 0 0 8px;
|
||
font-size: 30px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.8px;
|
||
line-height: 1.1;
|
||
}
|
||
.lrnArticleHero__sum {
|
||
margin: 0;
|
||
font-size: 14.5px;
|
||
line-height: 1.5;
|
||
color: rgba(255, 255, 255, 0.92);
|
||
}
|
||
|
||
/* Тело статьи — светлое, в стиле вики (белый фон, тёмный текст). */
|
||
.lrnArticleBody {
|
||
background: #ffffff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 18px;
|
||
padding: 28px 32px;
|
||
color: #334155;
|
||
font-size: 14.5px;
|
||
line-height: 1.7;
|
||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
|
||
}
|
||
.lrnArticleBody p { margin: 0 0 14px; }
|
||
.lrnArticleBody p:last-child { margin-bottom: 0; }
|
||
.lrnArticleBody ul {
|
||
margin: 0 0 14px;
|
||
padding-left: 22px;
|
||
}
|
||
.lrnArticleBody li { margin-bottom: 8px; line-height: 1.6; }
|
||
.lrnArticleBody li:last-child { margin-bottom: 0; }
|
||
.lrnArticleBody b { color: #0f172a; font-weight: 800; }
|
||
.lrnArticleBody code {
|
||
background: #e0e8ff;
|
||
color: #3357ff;
|
||
padding: 2px 7px;
|
||
border-radius: 6px;
|
||
font-family: Consolas, Menlo, "Courier New", monospace;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* подзаголовок в статье */
|
||
.lrnH {
|
||
margin: 24px 0 12px;
|
||
font-size: 19px;
|
||
font-weight: 900;
|
||
color: #0f172a;
|
||
letter-spacing: -0.3px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #eef2f7;
|
||
}
|
||
.lrnArticleBody > :first-child { margin-top: 0; }
|
||
|
||
/* шаг инструкции */
|
||
.lrnStep {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
margin: 0 0 12px;
|
||
}
|
||
.lrnStep__n {
|
||
flex-shrink: 0;
|
||
width: 26px; height: 26px;
|
||
border-radius: 50%;
|
||
background: #3357ff;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.lrnStep__body { flex: 1; padding-top: 2px; }
|
||
|
||
/* плашки совет / внимание / отлично */
|
||
.lrnBox {
|
||
display: flex;
|
||
gap: 11px;
|
||
align-items: flex-start;
|
||
border-radius: 11px;
|
||
padding: 12px 14px;
|
||
margin: 14px 0;
|
||
font-size: 13.5px;
|
||
line-height: 1.6;
|
||
border: 1px solid;
|
||
border-left-width: 4px;
|
||
}
|
||
.lrnBox__ico { flex-shrink: 0; line-height: 0; margin-top: 1px; }
|
||
/* плашки — светлые, в стиле вики */
|
||
.lrnBox--tip {
|
||
background: #fffbeb;
|
||
border-color: #fde68a;
|
||
border-left-color: #f59e0b;
|
||
color: #78350f;
|
||
}
|
||
.lrnBox--tip .lrnBox__ico { color: #b45309; }
|
||
.lrnBox--warn {
|
||
background: #fef2f2;
|
||
border-color: #fecaca;
|
||
border-left-color: #ef4444;
|
||
color: #7f1d1d;
|
||
}
|
||
.lrnBox--warn .lrnBox__ico { color: #dc2626; }
|
||
.lrnBox--ok {
|
||
background: #f0fdf4;
|
||
border-color: #bbf7d0;
|
||
border-left-color: #16a34a;
|
||
color: #14532d;
|
||
}
|
||
.lrnBox--ok .lrnBox__ico { color: #16a34a; }
|
||
.lrnBox code { background: rgba(0, 0, 0, 0.06); }
|
||
|
||
/* подвал статьи — следующая */
|
||
.lrnNext {
|
||
background: #ffffff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 16px;
|
||
padding: 18px 22px;
|
||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
|
||
}
|
||
.lrnNext__label {
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.lrnNext__btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
width: 100%;
|
||
background: #f1f5f9;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
padding: 14px 16px;
|
||
cursor: pointer;
|
||
color: #0f172a;
|
||
font-family: inherit;
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
transition: all 160ms ease;
|
||
}
|
||
.lrnNext__btn:hover { border-color: #3357ff; background: #e0e8ff; }
|
||
.lrnNext__ico {
|
||
flex-shrink: 0;
|
||
width: 36px; height: 36px;
|
||
border-radius: 10px;
|
||
background: #3357ff;
|
||
color: #fff;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.lrnNext__arrow { margin-left: auto; color: #3357ff; font-size: 18px; }
|
||
|
||
@media (max-width: 980px) {
|
||
.lrnHero, .lrnArticleHero { flex-direction: column; text-align: center; }
|
||
.lrnArticleBody { padding: 20px; }
|
||
}
|
||
`;
|
||
|
||
export default KubikonLearn;
|