studio/src/admin-preview/GdDecoPreview.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

218 lines
11 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.

/**
* GdDecoPreview — превью декораций ландшафта (10 эпох × 10 = 100).
* Маршрут: /admin-preview/gd-deco
* Multi-select: до 5 моделей на эпоху. Выбор → kubikon3d_savegame
* (project_id=295, namespace='gd_deco_choices'). data = { 1: ['d1_v1','d1_v3',...] }.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../api/API';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { DECO_CATALOG, EPOCH_INFO, getDecoByEpoch } from './gdDeco/decoFactories';
const CHOICES_PID = 295;
const CHOICES_NS = 'gd_deco_choices';
const MAX_PER_EPOCH = 5;
function getUserId() {
try { const t = localStorage.getItem('Authorization'); if (!t) return 0; return Number(jwtDecode(t).id) || 0; } catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {}
return cfg;
});
function DecoCard({ deco, isChosen, onToggle, disabled }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!wrapRef.current) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) setIsVisible(e.isIntersecting);
}, { rootMargin: '200px', threshold: 0.01 });
io.observe(wrapRef.current);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !canvasRef.current) return;
let engine = null, scene = null;
try {
engine = new Engine(canvasRef.current, true, { stencil: false, antialias: true });
scene = new Scene(engine);
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
const camera = new ArcRotateCamera('cam', -Math.PI / 2.5, Math.PI / 2.7, 5, new Vector3(0, 1.2, 0), scene);
camera.attachControl(canvasRef.current, false);
camera.minZ = 0.1; camera.maxZ = 50;
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.7;
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
sun.intensity = 0.8;
const floor = MeshBuilder.CreateGround('floor', { width: 4, height: 4 }, scene);
const fmat = new StandardMaterial('fmat', scene);
fmat.diffuseColor = new Color3(0.18, 0.22, 0.20);
floor.material = fmat;
const handle = deco.make(scene, `prev_${deco.id}`);
if (handle && handle.root) handle.root.position.y = 0;
scene.onBeforeRenderObservable.add(() => {
if (handle && handle.root) handle.root.rotation.y += 0.008;
});
engine.runRenderLoop(() => scene.render());
return () => {
try { engine && engine.stopRenderLoop(); } catch (e) {}
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
try { scene && scene.dispose(); } catch (e) {}
try { engine && engine.dispose(); } catch (e) {}
};
} catch (e) { console.warn('[DecoCard]', e); return () => {}; }
}, [isVisible, deco]);
return (
<div
ref={wrapRef}
onClick={() => { if (!disabled || isChosen) onToggle(); }}
style={{
...cardStyle,
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
cursor: disabled && !isChosen ? 'not-allowed' : 'pointer',
opacity: disabled && !isChosen ? 0.45 : 1,
}}
>
{isVisible
? <canvas ref={canvasRef} style={{ width: '100%', height: 200, display: 'block', background: '#0a1020' }} />
: <div style={{ width: '100%', height: 200, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}></div>}
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff' }}>{deco.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{deco.id}</code></div>
</div>
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}></span>}
</div>
</div>
);
}
export default function GdDecoPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
// choices: { '1': ['d1_v1','d1_v4',...], '2': [...], ... }
const [choices, setChoices] = useState({});
const [status, setStatus] = useState('idle');
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => setChoices(r.data?.data || {}))
.catch(() => {});
}, []);
const toggle = (epoch, decoId) => {
setChoices(prev => {
const arr = prev[epoch] || [];
const has = arr.includes(decoId);
let next;
if (has) {
next = arr.filter(x => x !== decoId);
} else {
if (arr.length >= MAX_PER_EPOCH) return prev;
next = [...arr, decoId];
}
return { ...prev, [epoch]: next };
});
setStatus('idle');
};
const save = async () => {
const uid = getUserId();
if (!uid) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: choices });
setStatus('saved');
} catch (e) { console.warn('[GdDecoPreview] save failed', e); setStatus('error'); }
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
const totalChosen = Object.values(choices).reduce((s, arr) => s + (arr?.length || 0), 0);
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 22, color: '#22ff66' }}>GD Декорации ландшафта ({DECO_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{totalChosen}</span> (до 5 на эпоху)
</div>
<button onClick={save} disabled={status === 'loading' || totalChosen === 0} style={{
...btnPrimary, opacity: status === 'loading' || totalChosen === 0 ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
</div>
{EPOCH_INFO.map(epoch => {
const items = getDecoByEpoch(epoch.n);
const arr = choices[epoch.n] || [];
const fullyChosen = arr.length >= MAX_PER_EPOCH;
return (
<div key={epoch.n} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 22, color: epoch.color, marginBottom: 12, borderBottom: `2px solid ${epoch.color}33`, paddingBottom: 8 }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
<span style={{ fontSize: 14, color: '#666', marginLeft: 12 }}>L{(epoch.n - 1) * 10 + 1} L{epoch.n * 10}</span>
<span style={{ fontSize: 14, color: fullyChosen ? '#22ff66' : '#888', marginLeft: 12 }}>
выбрано {arr.length}/{MAX_PER_EPOCH}
</span>
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 16,
}}>
{items.map(deco => (
<DecoCard
key={deco.id}
deco={deco}
isChosen={arr.includes(deco.id)}
onToggle={() => toggle(epoch.n, deco.id)}
disabled={fullyChosen}
/>
))}
</div>
</div>
);
})}
</div>
);
}
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #22ff66, #44aaff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };