import React, { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { jwtDecode } from 'jwt-decode'; import { useAuth } from '../auth/AuthContext.jsx'; import * as Kubikon3DApi from '../api/Kubikon3DService'; import { USER_addres } from '../api/API'; import cl from './KubikonStudio.module.css'; import { TEMPLATES } from './templates'; import { ARTICLES as LEARN_ARTICLES } from './learnArticles'; import { generateAllTemplateScreenshots } from './templateScreenshots'; import PublishStatusBadge from '../editor/PublishStatusBadge'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import useDeviceType from '../hooks/useDeviceType'; import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub'; import PleeseReg from '../components/PleeseReg/PleeseReg'; import Icon from '../editor/Icon'; function getCurrentUserId() { try { const token = localStorage.getItem('Authorization'); if (!token) return null; return jwtDecode(token).id; } catch { return null; } } /** Жанры — для отображения в карточках. Должно совпадать с GENRES в GameSettingsModal. */ const GENRE_NAME = { platformer: 'Платформер', racing: 'Гонки', shooter: 'Шутер', sandbox: 'Песочница', adventure: 'Приключение', puzzle: 'Головоломка', tycoon: 'Тайкун', rpg: 'РПГ', other: 'Другое', }; /** * Хелпер рендера карточки проекта (используется в нескольких секциях). */ function renderProjectCard(p, navigate, genreMap, onDeleteClick, cl, requireAuth = (a) => a()) { const open = () => requireAuth(() => navigate(`/edit/${p.id}`)); return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }} title={p.title} >
{p.thumbnail ? ( {p.title} ) : (
)} {p.status && p.status !== 'draft' && (
)}
{p.title}
{genreMap[p.genre] || ''} {p.genre && ·} {p.updated_at?.slice(0, 10) || '—'}
); } /** * KubikonStudio — стартовый экран Studio для платформы Рублокс * (наш аналог Roblox Studio с Minecraft-эстетикой). * * На этом экране (как в Roblox Studio Home): * - крупная карточка «Создать новую игру» * - сетка шаблонов (Платформер, Гонки, Шутер, Песочница, Пустой мир) * - блок «Мои игры» (пока пустой) * - блок «Последние» * - блок «Обучение» * * Доступ: открыт всем авторизованным пользователям (раньше — только admin). */ const KubikonStudio = () => { const navigate = useNavigate(); const { isAuthenticated, isLoading, user } = useAuth(); // Главный сайт Рублокса — туда уходит «Профиль» (свой реальный профиль // у студии нет, профили живут на rublox.pro). const RUBLOX_HOME = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_RUBLOX_HOME) || 'https://rublox.pro/app'; const { isDesktop } = useDeviceType(); // Активная вкладка. Начальное значение можно задать через ?tab= в // URL — так на Studio переходят из вики (у неё нет своих вкладок). const [searchParams] = useSearchParams(); const VALID_TABS = ['home', 'recent', 'my', 'templates', 'archive']; const initialTab = VALID_TABS.includes(searchParams.get('tab')) ? searchParams.get('tab') : 'home'; const [activeTab, setActiveTab] = useState(initialTab); const [myProjects, setMyProjects] = useState([]); const [myProjectsLoading, setMyProjectsLoading] = useState(true); const [creatingTemplate, setCreatingTemplate] = useState(null); // { id, title } | null — управляет модалкой подтверждения удаления const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); // Прогресс генерации скринов шаблонов const [genProgress, setGenProgress] = useState(null); // null | {current, total, id} // Имя пользователя для приветствия (как «Welcome, ...» в Roblox Studio) const [greetName, setGreetName] = useState(''); // Поиск по своим играм. searchOpen — раскрыт ли инпут в шапке. const [searchQuery, setSearchQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); // Гость МОЖЕТ просматривать студию — видит шаблоны и обучение. // При попытке создать игру / открыть редактор / документацию — модалка. const [showRegModal, setShowRegModal] = useState(false); const requireAuth = (action) => { if (isAuthenticated) { action(); } else { setShowRegModal(true); } }; // Грузим список «Мои игры» useEffect(() => { if (!isAuthenticated) return; const userId = getCurrentUserId(); if (!userId) { setMyProjectsLoading(false); return; } Kubikon3DApi.getMyProjects(userId) .then(res => setMyProjects(res.data?.projects || [])) .catch(err => console.error('[KubikonStudio] my-projects error:', err)) .finally(() => setMyProjectsLoading(false)); }, [isAuthenticated]); // Имя пользователя — для приветствия на главной. Берём из профиля // (в JWT-токене имени нет, только id). firstName — основное поле, // nikname — запасное. useEffect(() => { if (!isAuthenticated) { setGreetName(''); return; } const jwt = localStorage.getItem('Authorization') || localStorage.getItem('player_jwt'); fetch(`${USER_addres}/api/v1/users/profile`, { headers: { Authorization: jwt || '' } }) .then(r => r.ok ? r.json() : null) .then(d => { const p = d?.data || d || {}; setGreetName(p.firstName || p.nikname || ''); }) .catch(err => console.error('[KubikonStudio] profile name error:', err)); }, [isAuthenticated]); const handleDeleteProject = (projectId, projectTitle, e) => { e.stopPropagation(); setDeleteTarget({ id: projectId, title: projectTitle || 'Без названия' }); }; const confirmDelete = async () => { if (!deleteTarget || deleting) return; const userId = getCurrentUserId(); if (!userId) return; setDeleting(true); try { await Kubikon3DApi.deleteProject(deleteTarget.id, userId); setMyProjects(prev => prev.filter(p => p.id !== deleteTarget.id)); setDeleteTarget(null); } catch (err) { console.error('[KubikonStudio] delete error:', err); alert('Не удалось удалить проект'); } finally { setDeleting(false); } }; if (isLoading) { return null; } // «Изучай Студию» — карточки-статьи (контент в learnArticles.jsx, // страница чтения — KubikonLearn.jsx). Берём первые 6 статей. const GUIDES = LEARN_ARTICLES.slice(0, 6); const handleTemplateClick = async (tpl) => { if (!isAuthenticated) { setShowRegModal(true); return; } const userId = getCurrentUserId(); if (!userId || creatingTemplate) return; setCreatingTemplate(tpl.id); try { const state = tpl.build(); const payload = { user_id: userId, title: tpl.title, description: tpl.desc, genre: tpl.genre || 'sandbox', thumbnail: '', is_public: false, project_data: JSON.stringify(state), }; const res = await Kubikon3DApi.createProject(userId, payload); const newId = res.data?.id; if (newId) { navigate(`/edit/${newId}`); } else { throw new Error('No id from server'); } } catch (err) { console.error('[KubikonStudio] template create error:', err); // Лимит игр на аккаунт — показываем понятное сообщение с сервера const msg = err?.response?.data?.message; alert(msg || 'Не удалось создать игру из шаблона'); setCreatingTemplate(null); } }; const handleGenerateScreenshots = async () => { if (genProgress) return; setGenProgress({ current: 0, total: TEMPLATES.length, id: '' }); try { await generateAllTemplateScreenshots((p) => setGenProgress(p)); alert( `Готово! ${TEMPLATES.length} превью скачаны.\n\n` + `Скопируй файлы в:\npublic/assets/kubikon-templates/` ); } catch (err) { console.error('[KubikonStudio] generate screenshots error:', err); alert('Не удалось сгенерировать превью'); } finally { setGenProgress(null); } }; // Сортировка для секции «Последние» — по updated_at, top-6 const recentProjects = [...myProjects] .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')) .slice(0, 6); // Для вкладки «Мои игры»: опубликованные сверху, остальные снизу, // внутри каждой группы — по updated_at desc. // Умная лента: опубликованная игра — единый статус 'published'. const isApproved = (p) => p.status === 'published'; const sortedMyProjects = [...myProjects].sort((a, b) => { const pa = isApproved(a) ? 1 : 0; const pb = isApproved(b) ? 1 : 0; if (pa !== pb) return pb - pa; // одобренные первыми return (b.updated_at || '').localeCompare(a.updated_at || ''); }); const publishedProjects = sortedMyProjects.filter(isApproved); const draftProjects = sortedMyProjects.filter(p => !isApproved(p)); // 3D-редактор требует мышь и клавиатуру — на телефонах/планшетах // показываем заглушку. Проверка ПОСЛЕ всех хуков, чтобы не нарушить // правило rules-of-hooks (одинаковый порядок вызовов). if (!isDesktop) { return ; } return (
{/* === Модалка для гостя — войди или зарегистрируйся === */} {showRegModal && (
setShowRegModal(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 8, 35, 0.78)', backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, }} >
e.stopPropagation()} style={{ background: '#fff', borderRadius: 20, padding: 30, position: 'relative', width: '100%', maxWidth: 460, boxShadow: '0 30px 80px rgba(0,0,0,0.5)', }} >
)} {/* Левая боковая панель — навигация */} {/* Основной контент */}

{activeTab === 'home' ? (greetName ? `Привет, ${greetName}!` : 'Добро пожаловать в Студию') : activeTab === 'recent' ? 'История' : activeTab === 'my' ? 'Мои игры' : activeTab === 'templates' ? 'Шаблоны' : activeTab === 'archive' ? 'Архив' : 'Студия'}

{activeTab === 'home' ? 'Создавай 3D-игры и делись ими с друзьями' : activeTab === 'recent' ? 'Игры, которые ты открывал последними' : activeTab === 'my' ? 'Все твои игры — черновики и опубликованные' : activeTab === 'templates' ? 'Готовые заготовки — начни игру в один клик' : activeTab === 'archive' ? 'Сюда попадают игры, убранные в архив' : ''}

{/* Поиск по своим играм. Кнопка раскрывается в инпут; непустой запрос показывает результаты. */} {searchOpen ? (
setSearchQuery(e.target.value)} placeholder="Поиск по моим играм…" style={{ background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontFamily: 'inherit', fontSize: 13, width: 180, }} />
) : ( )}
{/* ═══ РЕЗУЛЬТАТЫ ПОИСКА — когда в шапке введён запрос. Ищем по своим играм (название/описание). Пока поиск активен — обычные секции скрыты. ═══ */} {searchQuery.trim() && (

Результаты поиска: «{searchQuery.trim()}»

{(() => { const q = searchQuery.trim().toLowerCase(); const found = myProjects.filter(p => (p.title || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q)); if (myProjectsLoading) { return
Загрузка...
; } if (found.length === 0) { return (
Ничего не найдено
По запросу «{searchQuery.trim()}» среди твоих игр ничего нет
); } return (
{found.map(p => renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
); })()}
)} {/* ═══ HERO — крупный баннер с кадром игры (как Roblox Studio). Кадр hero-226.png, текст слева, кнопка «Новая игра». ═══ */} {!searchQuery.trim() && activeTab === 'home' && (
Игра в Рублоксе
НАЧНИ ЗДЕСЬ

Создавай свои миры

Замки, подземелья, целые королевства. Редактор Рублокса справится с любой задумкой — начни прямо сейчас.

)} {/* ═══ ШАБЛОНЫ — «Открой шаблон» (главная + вкладка Шаблоны) ═══ */} {!searchQuery.trim() && (activeTab === 'home' || activeTab === 'templates') && (

Открой шаблон

Готовые заготовки популярных жанров — или начни с нуля
{TEMPLATES.map(t => ( ))}
)} {/* ═══ ИЗУЧАЙ СТУДИЮ — карточки-статьи (Discover Studio). Контент в learnArticles.jsx, читалка — KubikonLearn. ═══ */} {!searchQuery.trim() && activeTab === 'home' && (

Изучай Студию

{GUIDES.map(g => ( ))}
)} {/* ═══ ИСТОРИЯ — последние открытые игры ═══ */} {!searchQuery.trim() && activeTab === 'recent' && (
{!isAuthenticated ? (
Войди в аккаунт
История открытых игр доступна после входа
) : myProjectsLoading ? (
Загрузка...
) : recentProjects.length === 0 ? (
История пуста
Открой любую игру — она появится здесь
) : (
{recentProjects.map(p => renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
)}
)} {/* ═══ МОИ ИГРЫ — опубликованные + черновики ═══ */} {!searchQuery.trim() && activeTab === 'my' && ( <> {!isAuthenticated ? (
Войди в аккаунт
Твои игры хранятся в аккаунте Рублокса
) : myProjectsLoading ? (
Загрузка...
) : myProjects.length === 0 ? (
Пока пусто
Создай свою первую игру — она появится здесь
) : ( <> {publishedProjects.length > 0 && (

Опубликованные ({publishedProjects.length})

{publishedProjects.map(p => renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
)} {draftProjects.length > 0 && (

Черновики ({draftProjects.length})

{draftProjects.map(p => renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
)} )} )} {/* ═══ АРХИВ — пока пусто (заглушка) ═══ */} {!searchQuery.trim() && activeTab === 'archive' && (
Архив пуст
Сюда можно убирать игры, которые сейчас не нужны, но удалять их жалко. Скоро.
)} {/* Подвал-блок */}
Рублокс Studio · Альфа-версия
{/* Модалка подтверждения удаления */} {deleteTarget && (
!deleting && setDeleteTarget(null)}>
e.stopPropagation()}>
Удалить игру?
Игра «{deleteTarget.title}» будет удалена безвозвратно. Восстановить её будет нельзя.
)}
); }; export default KubikonStudio;