feat(studio): задача 17 — Toolbox переработан под Roblox Creator Store
Единый Toolbox вместо отдельной кнопки «Модель» в панели «Создать»: - 4 верхние вкладки как в Roblox: Магазин / Инвентарь / Недавние / Советы. - Магазин: главный экран с 6 плитками-категориями (3D-объекты / Эффекты / 2D-картинки / Готовые механики / Плагины / Аудио) + ряд «Популярное» (FREE). - Клик по категории → детальный список с поиском и подкатегориями; «← Категории». - 3D-объекты = 700+ моделей; Эффекты = эмиттер/луч/указатель/свет/триггер; Готовые механики = 12 китов; 2D/Плагины/Аудио = «Скоро будет». - Инвентарь = мои воксельные модели; Недавние = модели сообщества; Советы = гайд. - TopRibbon: кнопка «Модель» → «Toolbox» (открывает магазин); вкладка «Модель» переименована в «Редактор моделей» (создание своих воксельных ассетов). - CSS: topTabs/catGrid/catTile/trendRow/breadcrumb/soon/tips/freeBadge. Вся прежняя логика моделей (lazy-load, лайки, thumbnails) сохранена внутри новой структуры. Esc в категории → назад к плиткам. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d62739d709
commit
5e1a0edf9b
@ -282,6 +282,13 @@ const ToolboxModal = ({
|
|||||||
initialSection = 'standard',
|
initialSection = 'standard',
|
||||||
}) => {
|
}) => {
|
||||||
// Корневой раздел: 'standard' | 'mine' | 'community'
|
// Корневой раздел: 'standard' | 'mine' | 'community'
|
||||||
|
// === Roblox-style Toolbox (задача 17) ===
|
||||||
|
// Верхняя вкладка: 'store' | 'inventory' | 'recent' | 'tips'.
|
||||||
|
const [view, setView] = useState('store');
|
||||||
|
// Выбранная категория магазина (null = главный экран с 6 плитками):
|
||||||
|
// '3d' | 'fx' | '2d' | 'gameplay' | 'plugins' | 'audio'.
|
||||||
|
const [storeCat, setStoreCat] = useState(null);
|
||||||
|
|
||||||
const [section, setSection] = useState('standard');
|
const [section, setSection] = useState('standard');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [category, setCategory] = useState('all'); // для 'standard'
|
const [category, setCategory] = useState('all'); // для 'standard'
|
||||||
@ -297,6 +304,9 @@ const ToolboxModal = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
// initialSection маппится в новую структуру: mine → inventory.
|
||||||
|
if (initialSection === 'mine') { setView('inventory'); setStoreCat(null); }
|
||||||
|
else { setView('store'); setStoreCat(null); }
|
||||||
setSection(initialSection || 'standard');
|
setSection(initialSection || 'standard');
|
||||||
setCategory('all');
|
setCategory('all');
|
||||||
setUserKind('all');
|
setUserKind('all');
|
||||||
@ -307,13 +317,30 @@ const ToolboxModal = ({
|
|||||||
}
|
}
|
||||||
}, [open, initialSection]);
|
}, [open, initialSection]);
|
||||||
|
|
||||||
// Esc — закрыть
|
// Маппинг категории магазина → внутренний section (для lazy-load моделей).
|
||||||
|
const STORE_CAT_TO_SECTION = { '3d': 'standard', gameplay: 'gameplay', '2d': 'standard' };
|
||||||
|
const openStoreCategory = useCallback((catId) => {
|
||||||
|
setStoreCat(catId);
|
||||||
|
setSearch('');
|
||||||
|
setCategory('all');
|
||||||
|
setKitCat('all');
|
||||||
|
const sec = STORE_CAT_TO_SECTION[catId];
|
||||||
|
if (sec) setSection(sec);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Синхронизация верхней вкладки → внутренний section (для lazy-load).
|
||||||
|
useEffect(() => {
|
||||||
|
if (view === 'inventory') setSection('mine');
|
||||||
|
else if (view === 'recent') setSection('community');
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
// Esc — закрыть (если открыта категория магазина — сначала назад к плиткам)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e) => { if (e.key === 'Escape') { if (view === 'store' && storeCat) setStoreCat(null); else onClose(); } };
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose, view, storeCat]);
|
||||||
|
|
||||||
// Lazy-load моих моделей при переключении на 'mine'
|
// Lazy-load моих моделей при переключении на 'mine'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -438,6 +465,27 @@ const ToolboxModal = ({
|
|||||||
? (myModels?.length || 0)
|
? (myModels?.length || 0)
|
||||||
: (communityModels?.length || 0);
|
: (communityModels?.length || 0);
|
||||||
|
|
||||||
|
// 6 категорий магазина (как в Roblox Creator Store).
|
||||||
|
const STORE_CATEGORIES = [
|
||||||
|
{ id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' },
|
||||||
|
{ id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' },
|
||||||
|
{ id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' },
|
||||||
|
{ id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: '12 механик: вставил — работает' },
|
||||||
|
{ id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' },
|
||||||
|
{ id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' },
|
||||||
|
];
|
||||||
|
// Trending — что популярно (для главного экрана магазина). Берём яркие киты.
|
||||||
|
const TRENDING = GAMEPLAY_KITS.filter(k =>
|
||||||
|
['shift-to-run', 'day-night-cycle', 'loot-crate', 'confetti'].includes(k.id));
|
||||||
|
// Эффекты-примитивы для категории «Эффекты».
|
||||||
|
const FX_ITEMS = [
|
||||||
|
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'sparkles', desc: 'Источник частиц (огонь/искры/дым)' },
|
||||||
|
{ id: 'beam', name: 'Луч (beam)', icon: 'zap', desc: 'Бегущий луч между точками' },
|
||||||
|
{ id: 'pointer', name: 'Указатель-стрелка', icon: 'arrow-up', desc: 'Парящая стрелка-подсказка' },
|
||||||
|
{ id: 'light', name: 'Источник света', icon: 'sun', desc: 'Точечная лампа' },
|
||||||
|
{ id: 'checkpoint', name: 'Триггер-зона', icon: 'flag', desc: 'Невидимая зона-триггер' },
|
||||||
|
];
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
// Обработчик выбора пользовательской модели — пока stub.
|
// Обработчик выбора пользовательской модели — пока stub.
|
||||||
@ -503,67 +551,63 @@ const ToolboxModal = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className={cl.modal}>
|
<div className={cl.modal}>
|
||||||
|
{/* === Шапка с 4 верхними вкладками (как Roblox Creator Store) === */}
|
||||||
<header className={cl.header}>
|
<header className={cl.header}>
|
||||||
<h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Icon name="box" size={20} /> Тулбокс — библиотека объектов
|
<Icon name="box" size={20} /> Toolbox
|
||||||
</h2>
|
</h2>
|
||||||
<div className={cl.headerInfo}>
|
|
||||||
{section === 'standard'
|
|
||||||
? `Показано ${visibleCount} из ${totalForSection}`
|
|
||||||
: section === 'gameplay'
|
|
||||||
? `${kitsFiltered.length} готовых механик`
|
|
||||||
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
|
|
||||||
? '...'
|
|
||||||
: `${visibleCount} из ${totalForSection}`}
|
|
||||||
</div>
|
|
||||||
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
|
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
|
||||||
<Icon name="close" size={14} />
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Раздел: Стандартные / Мои / Сообщество */}
|
<div className={cl.topTabs}>
|
||||||
<div className={cl.sectionTabs}>
|
{[
|
||||||
<button
|
{ id: 'store', label: 'Магазин', icon: 'box' },
|
||||||
className={`${cl.sectionTab} ${section === 'standard' ? cl.sectionTabActive : ''}`}
|
{ id: 'inventory', label: 'Инвентарь', icon: 'grid' },
|
||||||
onClick={() => setSection('standard')}
|
{ id: 'recent', label: 'Недавние', icon: 'clock' },
|
||||||
>
|
{ id: 'tips', label: 'Советы', icon: 'bulb' },
|
||||||
<Icon name="library" size={15} /> Стандартные
|
].map(t => (
|
||||||
</button>
|
<button
|
||||||
<button
|
key={t.id}
|
||||||
className={`${cl.sectionTab} ${section === 'mine' ? cl.sectionTabActive : ''}`}
|
className={`${cl.topTab} ${view === t.id ? cl.topTabActive : ''}`}
|
||||||
onClick={() => setSection('mine')}
|
onClick={() => { setView(t.id); setStoreCat(null); setSearch(''); }}
|
||||||
>
|
title={t.label}
|
||||||
<Icon name="user" size={15} /> Мои модели
|
>
|
||||||
</button>
|
<Icon name={t.icon} size={18} />
|
||||||
<button
|
<span>{t.label}</span>
|
||||||
className={`${cl.sectionTab} ${section === 'community' ? cl.sectionTabActive : ''}`}
|
</button>
|
||||||
onClick={() => setSection('community')}
|
))}
|
||||||
>
|
|
||||||
<Icon name="globe" size={15} /> Сообщество
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${cl.sectionTab} ${section === 'gameplay' ? cl.sectionTabActive : ''}`}
|
|
||||||
onClick={() => setSection('gameplay')}
|
|
||||||
>
|
|
||||||
<Icon name="zap" size={15} /> Готовые механики
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cl.searchBar}>
|
{/* Поиск — скрыт только на главном экране магазина и в советах */}
|
||||||
<input
|
{!(view === 'store' && !storeCat) && view !== 'tips' && (
|
||||||
type="text"
|
<div className={cl.searchBar}>
|
||||||
className={cl.searchInput}
|
<input
|
||||||
placeholder={section === 'standard'
|
type="text"
|
||||||
? 'Поиск по названию, категории...'
|
className={cl.searchInput}
|
||||||
: 'Поиск по названию модели или автору...'}
|
placeholder="Поиск по названию..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Подкатегории зависят от раздела */}
|
{/* Хлебные крошки/назад при открытой категории магазина */}
|
||||||
{section === 'standard' && (
|
{view === 'store' && storeCat && (
|
||||||
|
<div className={cl.breadcrumb}>
|
||||||
|
<button className={cl.backBtn} onClick={() => setStoreCat(null)}>
|
||||||
|
<Icon name="arrow-left" size={14} /> Категории
|
||||||
|
</button>
|
||||||
|
<span className={cl.crumbCurrent}>
|
||||||
|
{(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подкатегории standard (3D) */}
|
||||||
|
{view === 'store' && storeCat === '3d' && (
|
||||||
<div className={cl.categoryTabs}>
|
<div className={cl.categoryTabs}>
|
||||||
{standardCategoriesWithCount.map(c => (
|
{standardCategoriesWithCount.map(c => (
|
||||||
<button
|
<button
|
||||||
@ -578,151 +622,180 @@ const ToolboxModal = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Подкатегории механик */}
|
||||||
{section === 'gameplay' && (
|
{view === 'store' && storeCat === 'gameplay' && (
|
||||||
<div className={cl.categoryTabs}>
|
<div className={cl.categoryTabs}>
|
||||||
{KIT_CATEGORIES.map(c => (
|
{KIT_CATEGORIES.map(c => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className={`${cl.categoryTab} ${kitCat === c.id ? cl.categoryTabActive : ''}`}
|
className={`${cl.categoryTab} ${kitCat === c.id ? cl.categoryTabActive : ''}`}
|
||||||
onClick={() => setKitCat(c.id)}
|
onClick={() => setKitCat(c.id)}
|
||||||
>
|
>{c.label}</button>
|
||||||
{c.label}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Фильтр kind для инвентаря */}
|
||||||
|
{view === 'inventory' && (
|
||||||
|
<div className={cl.categoryTabs}>
|
||||||
|
<button className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('all')}><Icon name="library" size={14} /> Все</button>
|
||||||
|
<button className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('voxel')}><Icon name="square" size={14} /> Воксельные</button>
|
||||||
|
<button className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('smooth')}><Icon name="sphere" size={14} /> Гладкие</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ====================== КОНТЕНТ ====================== */}
|
||||||
|
|
||||||
|
{/* --- МАГАЗИН: главный экран (6 плиток + Trending) --- */}
|
||||||
|
{view === 'store' && !storeCat && (
|
||||||
|
<div className={cl.storeHome}>
|
||||||
|
<div className={cl.sectionLabel}>Категории</div>
|
||||||
|
<div className={cl.catGrid}>
|
||||||
|
{STORE_CATEGORIES.map(c => (
|
||||||
|
<button key={c.id} className={cl.catTile} onClick={() => openStoreCategory(c.id)}>
|
||||||
|
<div className={cl.catTileIcon}><Icon name={c.icon} size={26} /></div>
|
||||||
|
<div className={cl.catTileLabel}>{c.label}</div>
|
||||||
|
<div className={cl.catTileDesc}>{c.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={cl.sectionLabel} style={{ marginTop: 18 }}>
|
||||||
|
<Icon name="trending" size={15} /> Популярное
|
||||||
|
</div>
|
||||||
|
<div className={cl.trendRow}>
|
||||||
|
{TRENDING.map(kit => (
|
||||||
|
<button key={kit.id} className={cl.trendCard}
|
||||||
|
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
|
||||||
|
<div className={cl.trendIcon}><Icon name={kit.icon || 'zap'} size={28} /></div>
|
||||||
|
<div className={cl.trendName}>{kit.name}</div>
|
||||||
|
<div className={cl.freeBadge}>FREE</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- МАГАЗИН: категория 3D-объекты --- */}
|
||||||
|
{view === 'store' && storeCat === '3d' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{standardFiltered.length === 0
|
||||||
|
? <div className={cl.empty}>Ничего не найдено</div>
|
||||||
|
: standardFiltered.map(m => (
|
||||||
|
<ThumbCard key={m.id} model={m} active={activeId === m.id}
|
||||||
|
onPick={() => { onPick(m.id); onClose(); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- МАГАЗИН: Эффекты --- */}
|
||||||
|
{view === 'store' && storeCat === 'fx' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{FX_ITEMS.filter(f => !search.trim() || f.name.toLowerCase().includes(search.trim().toLowerCase())).map(f => (
|
||||||
|
<button key={f.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
|
||||||
|
onClick={() => { onPick('primitive:' + f.id); onClose(); }} title={f.desc}>
|
||||||
|
<div className={cl.cardIconWrap}>
|
||||||
|
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(255,90,176,0.22), rgba(255,210,58,0.18))' }}>
|
||||||
|
<Icon name={f.icon} size={30} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cl.cardName}>{f.name}</div>
|
||||||
|
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8 }}>{f.desc}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(section === 'mine' || section === 'community') && (
|
{/* --- МАГАЗИН: Готовые механики --- */}
|
||||||
<div className={cl.categoryTabs}>
|
{view === 'store' && storeCat === 'gameplay' && (
|
||||||
<button
|
<div className={cl.grid}>
|
||||||
className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`}
|
{kitsFiltered.length === 0
|
||||||
onClick={() => setUserKind('all')}
|
? <div className={cl.empty}>Ничего не найдено</div>
|
||||||
>
|
: kitsFiltered.map(kit => (
|
||||||
<Icon name="library" size={14} /> Все
|
<button key={kit.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
|
||||||
</button>
|
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
|
||||||
<button
|
<div className={cl.cardIconWrap}>
|
||||||
className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`}
|
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))' }}>
|
||||||
onClick={() => setUserKind('voxel')}
|
<Icon name={kit.icon || 'zap'} size={30} />
|
||||||
>
|
</div>
|
||||||
<Icon name="square" size={14} /> Воксельные
|
</div>
|
||||||
</button>
|
<div className={cl.cardName}>{kit.name}</div>
|
||||||
<button
|
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8, lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{kit.desc}</div>
|
||||||
className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`}
|
<div className={cl.freeBadge}>FREE</div>
|
||||||
onClick={() => setUserKind('smooth')}
|
</button>
|
||||||
>
|
))}
|
||||||
<Icon name="sphere" size={14} /> Гладкие
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Контент === */}
|
{/* --- МАГАЗИН: 2D-картинки / Плагины / Аудио — пока «скоро» --- */}
|
||||||
<div className={cl.grid}>
|
{view === 'store' && (storeCat === '2d' || storeCat === 'plugins' || storeCat === 'audio') && (
|
||||||
{section === 'standard' && (
|
<div className={cl.soon}>
|
||||||
standardFiltered.length === 0 ? (
|
<Icon name={storeCat === 'audio' ? 'sound' : storeCat === '2d' ? 'image' : 'puzzle'} size={42} />
|
||||||
<div className={cl.empty}>Ничего не найдено</div>
|
<div className={cl.soonTitle}>Скоро будет</div>
|
||||||
) : (
|
<div className={cl.soonText}>
|
||||||
standardFiltered.map(m => (
|
{storeCat === '2d' && 'Иконки и текстуры для интерфейса появятся в следующем обновлении.'}
|
||||||
<ThumbCard
|
{storeCat === 'plugins' && 'Плагины-расширения студии — в разработке (фаза T4).'}
|
||||||
key={m.id}
|
{storeCat === 'audio' && 'Библиотека звуков и музыки — в разработке.'}
|
||||||
model={m}
|
</div>
|
||||||
active={activeId === m.id}
|
</div>
|
||||||
onPick={() => { onPick(m.id); onClose(); }}
|
)}
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{section === 'mine' && (
|
{/* --- ИНВЕНТАРЬ: мои модели --- */}
|
||||||
loading || myModels === null ? (
|
{view === 'inventory' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{loading || myModels === null ? (
|
||||||
<div className={cl.empty}>⏳ Загрузка...</div>
|
<div className={cl.empty}>⏳ Загрузка...</div>
|
||||||
) : !userId ? (
|
) : !userId ? (
|
||||||
<div className={cl.empty}>
|
<div className={cl.empty}>Войдите в аккаунт, чтобы видеть свои модели</div>
|
||||||
Войдите в аккаунт, чтобы видеть свои модели
|
|
||||||
</div>
|
|
||||||
) : loadError ? (
|
) : loadError ? (
|
||||||
<div className={cl.empty}>{loadError}</div>
|
<div className={cl.empty}>{loadError}</div>
|
||||||
) : mineFiltered.length === 0 ? (
|
) : mineFiltered.length === 0 ? (
|
||||||
<div className={cl.empty}>
|
<div className={cl.empty}>
|
||||||
{myModels.length === 0
|
{myModels.length === 0
|
||||||
? 'У вас пока нет своих моделей. Создайте их во вкладке «Модель» → «Воксельная» или «Гладкая».'
|
? 'У вас пока нет своих моделей. Создайте их в воксельном редакторе.'
|
||||||
: 'Ничего не найдено по фильтру'}
|
: 'Ничего не найдено по фильтру'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
mineFiltered.map(m => (
|
mineFiltered.map(m => (
|
||||||
<UserModelCard
|
<UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
|
||||||
key={m.id}
|
onPick={() => handlePickUserModel(m)} isMine
|
||||||
model={m}
|
onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} />
|
||||||
active={activeId === userModelKey(m)}
|
|
||||||
onPick={() => handlePickUserModel(m)}
|
|
||||||
isMine
|
|
||||||
onEdit={onEditUserModel}
|
|
||||||
onSettings={onUserModelSettings}
|
|
||||||
onDelete={onDeleteUserModel}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'community' && (
|
{/* --- НЕДАВНИЕ: сообщество (популярные модели сообщества) --- */}
|
||||||
loading || communityModels === null ? (
|
{view === 'recent' && (
|
||||||
|
<div className={cl.grid}>
|
||||||
|
{communityModels === null ? (
|
||||||
<div className={cl.empty}>⏳ Загрузка...</div>
|
<div className={cl.empty}>⏳ Загрузка...</div>
|
||||||
) : loadError ? (
|
|
||||||
<div className={cl.empty}>{loadError}</div>
|
|
||||||
) : communityFiltered.length === 0 ? (
|
) : communityFiltered.length === 0 ? (
|
||||||
<div className={cl.empty}>
|
<div className={cl.empty}>Пока пусто. Используй ассеты — они появятся здесь.</div>
|
||||||
{communityModels.length === 0
|
|
||||||
? 'Пока нет опубликованных моделей сообщества. Будь первым!'
|
|
||||||
: 'Ничего не найдено по фильтру'}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
communityFiltered.map(m => (
|
communityFiltered.map(m => (
|
||||||
<UserModelCard
|
<UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
|
||||||
key={m.id}
|
|
||||||
model={m}
|
|
||||||
active={activeId === userModelKey(m)}
|
|
||||||
onPick={() => handlePickUserModel(m)}
|
onPick={() => handlePickUserModel(m)}
|
||||||
isMine={userId != null && m.user_id === userId}
|
isMine={userId != null && m.user_id === userId}
|
||||||
onEdit={onEditUserModel}
|
onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel}
|
||||||
onSettings={onUserModelSettings}
|
showSocial onLike={handleLikeModel} />
|
||||||
onDelete={onDeleteUserModel}
|
|
||||||
showSocial
|
|
||||||
onLike={handleLikeModel}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'gameplay' && (
|
{/* --- СОВЕТЫ --- */}
|
||||||
kitsFiltered.length === 0 ? (
|
{view === 'tips' && (
|
||||||
<div className={cl.empty}>Ничего не найдено</div>
|
<div className={cl.tips}>
|
||||||
) : (
|
<h3>Как пользоваться Toolbox</h3>
|
||||||
kitsFiltered.map(kit => (
|
<ul>
|
||||||
<button
|
<li><b>3D-объекты</b> — 700+ готовых моделей: деревья, дома, мебель, персонажи. Клик → объект появляется на сцене.</li>
|
||||||
key={kit.id}
|
<li><b>Готовые механики</b> — вставь поведение одним кликом: бег на Shift, смена дня/ночи, сундук с лутом, счётчик монет. Скрипт прикрепляется сам.</li>
|
||||||
type="button"
|
<li><b>Эффекты</b> — частицы, лучи, источники света, триггер-зоны.</li>
|
||||||
className={cl.card}
|
<li><b>Инвентарь</b> — твои воксельные модели, созданные в редакторе.</li>
|
||||||
onClick={() => { onPick('kit:' + kit.id); onClose(); }}
|
<li>Жми на категорию, ищи через поиск, кликни ассет — он добавится в проект.</li>
|
||||||
title={kit.desc}
|
</ul>
|
||||||
style={{ textAlign: 'left' }}
|
<p style={{ opacity: 0.7 }}>Собери целую игру, не написав ни строчки кода — просто перетаскивая готовые механики.</p>
|
||||||
>
|
</div>
|
||||||
<div className={cl.cardIconWrap}>
|
)}
|
||||||
<div className={cl.cardIconPlaceholder} style={{
|
|
||||||
background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))',
|
|
||||||
}}>
|
|
||||||
<Icon name={kit.icon || 'zap'} size={30} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={cl.cardName}>{kit.name}</div>
|
|
||||||
<div className={cl.cardCat} style={{
|
|
||||||
fontSize: 11, opacity: 0.8, lineHeight: 1.3,
|
|
||||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
||||||
}}>{kit.desc}</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -447,3 +447,134 @@
|
|||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ====================== Roblox-style Toolbox (задача 17) ====================== */
|
||||||
|
.topTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.topTab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 4px 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-dim, #9aa3b2);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.topTab:hover { color: var(--text, #e8ecf2); }
|
||||||
|
.topTabActive {
|
||||||
|
color: var(--accent, #4d6bff);
|
||||||
|
border-bottom-color: var(--accent, #4d6bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 16px 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.backBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
color: var(--text, #e8ecf2);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.backBtn:hover { background: rgba(255,255,255,0.12); }
|
||||||
|
.crumbCurrent { font-weight: 700; color: var(--text, #e8ecf2); }
|
||||||
|
|
||||||
|
.storeHome { overflow-y: auto; padding: 12px 16px 18px; flex: 1; }
|
||||||
|
.sectionLabel {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-weight: 700; font-size: 14px; color: var(--text, #e8ecf2);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.catGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.catTile {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.09);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: transform .1s, background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.catTile:hover {
|
||||||
|
background: rgba(77,107,255,0.12);
|
||||||
|
border-color: var(--accent, #4d6bff);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.catTileIcon { color: var(--accent, #4d6bff); margin-bottom: 4px; }
|
||||||
|
.catTileLabel { font-weight: 700; font-size: 15px; color: var(--text, #e8ecf2); }
|
||||||
|
.catTileDesc { font-size: 11px; opacity: 0.7; line-height: 1.3; }
|
||||||
|
|
||||||
|
.trendRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.trendCard {
|
||||||
|
position: relative;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
padding: 14px 8px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.09);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .1s, border-color .12s;
|
||||||
|
}
|
||||||
|
.trendCard:hover { transform: translateY(-2px); border-color: var(--accent, #4d6bff); }
|
||||||
|
.trendIcon {
|
||||||
|
width: 100%; height: 70px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: linear-gradient(135deg, rgba(77,107,255,0.22), rgba(54,213,122,0.16));
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text, #e8ecf2);
|
||||||
|
}
|
||||||
|
.trendName { font-size: 12px; font-weight: 600; text-align: center; color: var(--text, #e8ecf2); }
|
||||||
|
.freeBadge {
|
||||||
|
position: absolute; top: 8px; right: 8px;
|
||||||
|
font-size: 9px; font-weight: 800; letter-spacing: 0.5px;
|
||||||
|
color: #36d57a;
|
||||||
|
background: rgba(54,213,122,0.14);
|
||||||
|
padding: 2px 6px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soon {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 10px; padding: 40px;
|
||||||
|
color: var(--text-dim, #9aa3b2); text-align: center;
|
||||||
|
}
|
||||||
|
.soonTitle { font-size: 18px; font-weight: 700; color: var(--text, #e8ecf2); }
|
||||||
|
.soonText { font-size: 13px; max-width: 360px; opacity: 0.75; }
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
overflow-y: auto; padding: 16px 22px; flex: 1;
|
||||||
|
color: var(--text, #e8ecf2); line-height: 1.55;
|
||||||
|
}
|
||||||
|
.tips h3 { margin: 4px 0 12px; font-size: 17px; }
|
||||||
|
.tips ul { margin: 0 0 14px; padding-left: 18px; }
|
||||||
|
.tips li { margin-bottom: 9px; font-size: 13px; }
|
||||||
|
.tips b { color: var(--accent, #6f8bff); }
|
||||||
|
|||||||
@ -140,10 +140,12 @@ const Dropdown = ({ trigger, children }) => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'home', label: 'Главная', iconName: 'home' },
|
{ id: 'home', label: 'Главная', iconName: 'home' },
|
||||||
{ id: 'model', label: 'Модель', iconName: 'wrench' },
|
// Вкладка-редактор СВОИХ воксельных моделей (создание ассета).
|
||||||
{ id: 'test', label: 'Игра', iconName: 'gamepad' },
|
// Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»).
|
||||||
{ id: 'view', label: 'Вид', iconName: 'eye' },
|
{ id: 'model', label: 'Редактор моделей', iconName: 'wrench' },
|
||||||
|
{ id: 'test', label: 'Игра', iconName: 'gamepad' },
|
||||||
|
{ id: 'view', label: 'Вид', iconName: 'eye' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SNAP_OPTIONS = [
|
const SNAP_OPTIONS = [
|
||||||
@ -329,9 +331,9 @@ const TopRibbon = (props) => {
|
|||||||
title="Параметрическая фигура (куб/сфера/...)"
|
title="Параметрическая фигура (куб/сфера/...)"
|
||||||
/>
|
/>
|
||||||
<RibbonBtn
|
<RibbonBtn
|
||||||
iconName="trees" label="Модель"
|
iconName="box" label="Toolbox"
|
||||||
active={activeTool === 'model'}
|
onClick={onOpenStandardModels}
|
||||||
onClick={() => onToolChange('model')}
|
title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)"
|
||||||
/>
|
/>
|
||||||
<RibbonBtn
|
<RibbonBtn
|
||||||
iconName="image" label="Интерфейс"
|
iconName="image" label="Интерфейс"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user