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

564 lines
20 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, { 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;