803 lines
42 KiB
JavaScript
803 lines
42 KiB
JavaScript
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 (
|
||
<div
|
||
key={p.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={cl.templateCard}
|
||
onClick={open}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
open();
|
||
}
|
||
}}
|
||
title={p.title}
|
||
>
|
||
<div className={cl.templatePreview}>
|
||
{p.thumbnail ? (
|
||
<img src={p.thumbnail} alt={p.title} />
|
||
) : (
|
||
<div className={cl.templateEmoji}><Icon name="gamepad" size={14} /></div>
|
||
)}
|
||
{p.status && p.status !== 'draft' && (
|
||
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 5 }}>
|
||
<PublishStatusBadge status={p.status} comment={p.moderator_comment} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className={cl.templateBody}>
|
||
<div className={cl.templateTitle}>{p.title}</div>
|
||
<div className={cl.templateDesc}>
|
||
<span style={{ opacity: 0.92 }}>{genreMap[p.genre] || ''}</span>
|
||
{p.genre && <span style={{ margin: '0 6px', opacity: 0.4 }}>·</span>}
|
||
{p.updated_at?.slice(0, 10) || '—'}
|
||
<button
|
||
onClick={(e) => onDeleteClick(p.id, p.title, e)}
|
||
style={{
|
||
float: 'right', background: 'transparent', border: 'none',
|
||
color: 'var(--text-muted)', cursor: 'pointer', fontSize: 14, padding: 0
|
||
}}
|
||
title="Удалить"
|
||
><Icon name="delete" size={13} /> </button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 <KubikonDesktopOnlyStub feature="Рублокс" />;
|
||
}
|
||
|
||
return (
|
||
<div className={cl.studio}>
|
||
{/* === Модалка для гостя — войди или зарегистрируйся === */}
|
||
{showRegModal && (
|
||
<div
|
||
onClick={() => 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,
|
||
}}
|
||
>
|
||
<div
|
||
onClick={(e) => 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)',
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setShowRegModal(false)}
|
||
style={{
|
||
position: 'absolute', top: 12, right: 12,
|
||
width: 36, height: 36, borderRadius: 18,
|
||
border: 'none',
|
||
background: '#F3E8FF', color: '#1E1B4B',
|
||
fontSize: 20, fontWeight: 800,
|
||
cursor: 'pointer',
|
||
}}
|
||
aria-label="Закрыть"
|
||
><Icon name="close" size={14} /></button>
|
||
<PleeseReg textDefault='Чтобы создавать игры в Рублоксе' style={{width:'auto',height:'auto'}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Левая боковая панель — навигация */}
|
||
<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>
|
||
|
||
{/* Навигация в стиле Roblox Studio:
|
||
Новая игра · История · Главная · Мои игры · Шаблоны · Архив · ВИКИ */}
|
||
<nav className={cl.sidebarNav}>
|
||
{/* Новая игра — отдельная акцентная кнопка-действие */}
|
||
<button
|
||
className={cl.navNewGame}
|
||
onClick={() => requireAuth(() => navigate('/edit/new'))}
|
||
>
|
||
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
|
||
<span>Новая игра</span>
|
||
</button>
|
||
|
||
<button
|
||
className={`${cl.navItem} ${activeTab === 'recent' ? cl.navActive : ''}`}
|
||
onClick={() => setActiveTab('recent')}
|
||
>
|
||
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
|
||
<span>История</span>
|
||
</button>
|
||
<button
|
||
className={`${cl.navItem} ${activeTab === 'home' ? cl.navActive : ''}`}
|
||
onClick={() => setActiveTab('home')}
|
||
>
|
||
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
|
||
<span>Главная</span>
|
||
</button>
|
||
<button
|
||
className={`${cl.navItem} ${activeTab === 'my' ? cl.navActive : ''}`}
|
||
onClick={() => setActiveTab('my')}
|
||
>
|
||
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
|
||
<span>Мои игры</span>
|
||
</button>
|
||
<button
|
||
className={`${cl.navItem} ${activeTab === 'templates' ? cl.navActive : ''}`}
|
||
onClick={() => setActiveTab('templates')}
|
||
>
|
||
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
|
||
<span>Шаблоны</span>
|
||
</button>
|
||
<button
|
||
className={`${cl.navItem} ${activeTab === 'archive' ? cl.navActive : ''}`}
|
||
onClick={() => setActiveTab('archive')}
|
||
>
|
||
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
|
||
<span>Архив</span>
|
||
</button>
|
||
<button
|
||
className={cl.navItem}
|
||
onClick={() => navigate('/docs')}
|
||
title="Вики — учебник по редактору и 50 игр-уроков"
|
||
>
|
||
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
|
||
<span>ВИКИ</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<div className={cl.sidebarFooter}>
|
||
<button
|
||
className={cl.docsBtn}
|
||
onClick={() => navigate('/rules')}
|
||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
||
>
|
||
<Icon name="shield" size={15} /> Правила создания игр
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Основной контент */}
|
||
<main className={cl.main}>
|
||
<header className={cl.topBar}>
|
||
<div>
|
||
<h1 className={cl.pageTitle}>
|
||
{activeTab === 'home'
|
||
? (greetName ? `Привет, ${greetName}!` : 'Добро пожаловать в Студию')
|
||
: activeTab === 'recent' ? 'История'
|
||
: activeTab === 'my' ? 'Мои игры'
|
||
: activeTab === 'templates' ? 'Шаблоны'
|
||
: activeTab === 'archive' ? 'Архив'
|
||
: 'Студия'}
|
||
</h1>
|
||
<p className={cl.pageSub}>
|
||
{activeTab === 'home' ? 'Создавай 3D-игры и делись ими с друзьями'
|
||
: activeTab === 'recent' ? 'Игры, которые ты открывал последними'
|
||
: activeTab === 'my' ? 'Все твои игры — черновики и опубликованные'
|
||
: activeTab === 'templates' ? 'Готовые заготовки — начни игру в один клик'
|
||
: activeTab === 'archive' ? 'Сюда попадают игры, убранные в архив'
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
<div className={cl.topBarActions}>
|
||
{/* Поиск по своим играм. Кнопка раскрывается в
|
||
инпут; непустой запрос показывает результаты. */}
|
||
{searchOpen ? (
|
||
<div
|
||
className={cl.searchBox}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '0 6px 0 14px' }}
|
||
>
|
||
<Icon name="search" size={14} />
|
||
<input
|
||
autoFocus
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Поиск по моим играм…"
|
||
style={{
|
||
background: 'transparent', border: 'none', outline: 'none',
|
||
color: 'inherit', fontFamily: 'inherit', fontSize: 13,
|
||
width: 180,
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => { setSearchQuery(''); setSearchOpen(false); }}
|
||
style={{
|
||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||
color: 'inherit', fontSize: 16, padding: '6px 8px', lineHeight: 1,
|
||
}}
|
||
title="Закрыть поиск"
|
||
>×</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className={cl.searchBox}
|
||
onClick={() => setSearchOpen(true)}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||
>
|
||
<Icon name="search" size={14} /> Поиск
|
||
</button>
|
||
)}
|
||
<button
|
||
className={cl.profileBtn}
|
||
onClick={() => {
|
||
// Профили живут на rublox.pro, не в студии.
|
||
// У гостя профиля нет — открываем главную сайта.
|
||
const url = (isAuthenticated && user?.id)
|
||
? `${RUBLOX_HOME}/profile/${user.id}`
|
||
: RUBLOX_HOME;
|
||
window.open(url, '_blank', 'noopener');
|
||
}}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||
>
|
||
<Icon name="user" size={14} /> Профиль
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ═══ РЕЗУЛЬТАТЫ ПОИСКА — когда в шапке введён запрос.
|
||
Ищем по своим играм (название/описание). Пока
|
||
поиск активен — обычные секции скрыты. ═══ */}
|
||
{searchQuery.trim() && (
|
||
<section className={cl.section}>
|
||
<div className={cl.sectionHead}>
|
||
<h3 className={cl.sectionTitle}>
|
||
Результаты поиска: «{searchQuery.trim()}»
|
||
</h3>
|
||
</div>
|
||
{(() => {
|
||
const q = searchQuery.trim().toLowerCase();
|
||
const found = myProjects.filter(p =>
|
||
(p.title || '').toLowerCase().includes(q)
|
||
|| (p.description || '').toLowerCase().includes(q));
|
||
if (myProjectsLoading) {
|
||
return <div className={cl.emptyStateSmall}><Icon name="hourglass" size={13} /> Загрузка...</div>;
|
||
}
|
||
if (found.length === 0) {
|
||
return (
|
||
<div className={cl.emptyState}>
|
||
<div className={cl.emptyEmoji}><Icon name="search" size={14} /></div>
|
||
<div className={cl.emptyTitle}>Ничего не найдено</div>
|
||
<div className={cl.emptyDesc}>
|
||
По запросу «{searchQuery.trim()}» среди твоих игр ничего нет
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className={cl.templatesGrid}>
|
||
{found.map(p =>
|
||
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
|
||
</div>
|
||
);
|
||
})()}
|
||
</section>
|
||
)}
|
||
|
||
{/* ═══ HERO — крупный баннер с кадром игры (как Roblox Studio).
|
||
Кадр hero-226.png, текст слева, кнопка «Новая игра». ═══ */}
|
||
{!searchQuery.trim() && activeTab === 'home' && (
|
||
<section className={cl.hero}>
|
||
<img
|
||
className={cl.heroImg}
|
||
src="/wiki/hero/hero-226.png"
|
||
alt="Игра в Рублоксе"
|
||
/>
|
||
<div className={cl.heroOverlay} />
|
||
<div className={cl.heroContent}>
|
||
<div className={cl.heroBadge}>НАЧНИ ЗДЕСЬ</div>
|
||
<h2 className={cl.heroTitle}>Создавай свои миры</h2>
|
||
<p className={cl.heroDesc}>
|
||
Замки, подземелья, целые королевства. Редактор
|
||
Рублокса справится с любой задумкой — начни прямо
|
||
сейчас.
|
||
</p>
|
||
<button
|
||
className={cl.heroBtn}
|
||
onClick={() => requireAuth(() => navigate('/edit/new'))}
|
||
>
|
||
<Icon name="plus" size={15} /> Новая игра
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ═══ ШАБЛОНЫ — «Открой шаблон» (главная + вкладка Шаблоны) ═══ */}
|
||
{!searchQuery.trim() && (activeTab === 'home' || activeTab === 'templates') && (
|
||
<section className={cl.section}>
|
||
<div className={cl.sectionHead}>
|
||
<h3 className={cl.sectionTitle}>Открой шаблон</h3>
|
||
<span className={cl.sectionSub}>
|
||
Готовые заготовки популярных жанров — или начни с нуля
|
||
</span>
|
||
</div>
|
||
<div className={cl.tplGrid}>
|
||
{TEMPLATES.map(t => (
|
||
<button
|
||
key={t.id}
|
||
className={cl.tplCard}
|
||
disabled={!!creatingTemplate}
|
||
onClick={() => handleTemplateClick(t)}
|
||
title={`Создать игру из шаблона «${t.title}»`}
|
||
style={{ '--tpl-color': t.color }}
|
||
>
|
||
<div className={cl.tplImageWrap}>
|
||
<img
|
||
src={`/assets/kubikon-templates/${t.id}.jpg`}
|
||
alt={t.title}
|
||
className={cl.tplImage}
|
||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||
/>
|
||
{/* Фолбэк-фон при отсутствии картинки */}
|
||
<div
|
||
className={cl.tplFallback}
|
||
style={{ background: `linear-gradient(135deg, ${t.color} 0%, #2a2218 100%)` }}
|
||
>
|
||
<span className={cl.tplFallbackEmoji}>
|
||
<Icon name={t.icon} size={40} />
|
||
</span>
|
||
</div>
|
||
<div className={cl.tplOverlay} />
|
||
<div className={cl.tplContent}>
|
||
<div className={cl.tplBadge}>
|
||
<Icon name={t.icon} size={20} />
|
||
</div>
|
||
<div className={cl.tplTitle}>{t.title}</div>
|
||
<div className={cl.tplDesc}>{t.desc}</div>
|
||
</div>
|
||
<div className={cl.tplCta}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon name="play" size={14} /> Создать игру
|
||
</span>
|
||
</div>
|
||
{creatingTemplate === t.id && (
|
||
<div className={cl.tplLoading}>
|
||
<div className={cl.tplLoadingSpinner} />
|
||
<span>Создаём...</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ═══ ИЗУЧАЙ СТУДИЮ — карточки-статьи (Discover Studio).
|
||
Контент в learnArticles.jsx, читалка — KubikonLearn. ═══ */}
|
||
{!searchQuery.trim() && activeTab === 'home' && (
|
||
<section className={cl.section}>
|
||
<div className={cl.sectionHead}>
|
||
<h3 className={cl.sectionTitle}>Изучай Студию</h3>
|
||
<button className={cl.sectionMore} onClick={() => navigate('/learn')}>
|
||
Все статьи →
|
||
</button>
|
||
</div>
|
||
<div className={cl.guideGrid}>
|
||
{GUIDES.map(g => (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
className={cl.guideCard}
|
||
onClick={() => navigate('/learn/' + g.id)}
|
||
>
|
||
<div className={cl.guideImage}>
|
||
{/* запасной фон ПОД картинкой */}
|
||
<div
|
||
className={cl.guideFallback}
|
||
style={{ background: `linear-gradient(135deg, ${g.color} 0%, #15151c 120%)` }}
|
||
>
|
||
<Icon name="book-open" size={42} />
|
||
</div>
|
||
<img
|
||
className={cl.guideImg}
|
||
src={`/assets/kubikon-learn/${g.cover}`}
|
||
alt={g.title}
|
||
loading="lazy"
|
||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||
/>
|
||
</div>
|
||
<div className={cl.guideBody}>
|
||
<div className={cl.guideTitle}>{g.title}</div>
|
||
<div className={cl.guideDesc}>{g.summary}</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ═══ ИСТОРИЯ — последние открытые игры ═══ */}
|
||
{!searchQuery.trim() && activeTab === 'recent' && (
|
||
<section className={cl.section}>
|
||
{!isAuthenticated ? (
|
||
<div className={cl.emptyState}>
|
||
<div className={cl.emptyEmoji}><Icon name="clock" size={14} /></div>
|
||
<div className={cl.emptyTitle}>Войди в аккаунт</div>
|
||
<div className={cl.emptyDesc}>История открытых игр доступна после входа</div>
|
||
<button className={cl.emptyBtn} onClick={() => setShowRegModal(true)}>
|
||
Войти
|
||
</button>
|
||
</div>
|
||
) : myProjectsLoading ? (
|
||
<div className={cl.emptyStateSmall}><Icon name="hourglass" size={13} /> Загрузка...</div>
|
||
) : recentProjects.length === 0 ? (
|
||
<div className={cl.emptyState}>
|
||
<div className={cl.emptyEmoji}><Icon name="clock" size={14} /></div>
|
||
<div className={cl.emptyTitle}>История пуста</div>
|
||
<div className={cl.emptyDesc}>Открой любую игру — она появится здесь</div>
|
||
<button className={cl.emptyBtn} onClick={() => navigate('/edit/new')}>
|
||
+ Создать игру
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className={cl.templatesGrid}>
|
||
{recentProjects.map(p =>
|
||
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* ═══ МОИ ИГРЫ — опубликованные + черновики ═══ */}
|
||
{!searchQuery.trim() && activeTab === 'my' && (
|
||
<>
|
||
{!isAuthenticated ? (
|
||
<section className={cl.section}>
|
||
<div className={cl.emptyState}>
|
||
<div className={cl.emptyEmoji}><Icon name="folder" size={14} /></div>
|
||
<div className={cl.emptyTitle}>Войди в аккаунт</div>
|
||
<div className={cl.emptyDesc}>Твои игры хранятся в аккаунте Рублокса</div>
|
||
<button className={cl.emptyBtn} onClick={() => setShowRegModal(true)}>
|
||
Войти
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : myProjectsLoading ? (
|
||
<section className={cl.section}>
|
||
<div className={cl.emptyStateSmall}><Icon name="hourglass" size={13} /> Загрузка...</div>
|
||
</section>
|
||
) : myProjects.length === 0 ? (
|
||
<section className={cl.section}>
|
||
<div className={cl.emptyState}>
|
||
<div className={cl.emptyEmoji}><Icon name="package" size={14} /></div>
|
||
<div className={cl.emptyTitle}>Пока пусто</div>
|
||
<div className={cl.emptyDesc}>Создай свою первую игру — она появится здесь</div>
|
||
<button className={cl.emptyBtn} onClick={() => navigate('/edit/new')}>
|
||
+ Создать игру
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<>
|
||
{publishedProjects.length > 0 && (
|
||
<section className={cl.section}>
|
||
<div className={cl.sectionHead}>
|
||
<h3 className={cl.sectionTitle}>
|
||
<Icon name="globe" size={13} /> Опубликованные ({publishedProjects.length})
|
||
</h3>
|
||
</div>
|
||
<div className={cl.templatesGrid}>
|
||
{publishedProjects.map(p =>
|
||
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
{draftProjects.length > 0 && (
|
||
<section className={cl.section}>
|
||
<div className={cl.sectionHead}>
|
||
<h3 className={cl.sectionTitle}>
|
||
<Icon name="rename" size={13} /> Черновики ({draftProjects.length})
|
||
</h3>
|
||
</div>
|
||
<div className={cl.templatesGrid}>
|
||
{draftProjects.map(p =>
|
||
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ═══ АРХИВ — пока пусто (заглушка) ═══ */}
|
||
{!searchQuery.trim() && activeTab === 'archive' && (
|
||
<section className={cl.section}>
|
||
<div className={cl.emptyState}>
|
||
<div className={cl.emptyEmoji}><Icon name="archive" size={14} /></div>
|
||
<div className={cl.emptyTitle}>Архив пуст</div>
|
||
<div className={cl.emptyDesc}>
|
||
Сюда можно убирать игры, которые сейчас не нужны,
|
||
но удалять их жалко. Скоро.
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Подвал-блок */}
|
||
<section className={cl.footerBlock}>
|
||
<div className={cl.footerInfo}>
|
||
Рублокс Studio · Альфа-версия
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
{/* Модалка подтверждения удаления */}
|
||
{deleteTarget && (
|
||
<div className={cl.modalBackdrop} onClick={() => !deleting && setDeleteTarget(null)}>
|
||
<div className={cl.modalCard} onClick={(e) => e.stopPropagation()}>
|
||
<div className={cl.modalIcon}><Icon name="delete" size={14} /></div>
|
||
<div className={cl.modalTitle}>Удалить игру?</div>
|
||
<div className={cl.modalText}>
|
||
Игра <b>«{deleteTarget.title}»</b> будет удалена безвозвратно.
|
||
Восстановить её будет нельзя.
|
||
</div>
|
||
<div className={cl.modalActions}>
|
||
<button
|
||
className={cl.modalCancelBtn}
|
||
onClick={() => setDeleteTarget(null)}
|
||
disabled={deleting}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
className={cl.modalDeleteBtn}
|
||
onClick={confirmDelete}
|
||
disabled={deleting}
|
||
>
|
||
{deleting ? <><Icon name="hourglass" size={13} /> Удаляем...</> : <><Icon name="delete" size={13} /> Удалить</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default KubikonStudio;
|